Compare commits

...

36 Commits

Author SHA1 Message Date
Joseph Doherty 9063f10b1b [M5] mxaccess-asb: register_items retry on InvalidConnectionId — LIVE PATH WORKS
rust / build / test / clippy / fmt (push) Has been cancelled
End-to-end live path now functional: Connect → AuthenticateMe →
RegisterItems → Read → Disconnect. The example reads back the live
TestChildObject.TestInt value (99) over the wire on the first run.

Root-cause of the previous "InvalidConnectionId" mystery: it was
never an HMAC verification failure. `AuthenticateMe` is one-way
(`AsbContracts.cs:18`) and the server commits auth state
asynchronously after the request lands. A Register that follows too
quickly sees the connection in pre-authenticated state and returns
`AsbErrorCode.InvalidConnectionId` (= 1).

.NET's `MxAsbDataClient.RegisterMany` (`cs:191-204`) handles this
explicitly with a retry loop:

  for (int attempt = 1; attempt < 5
       && response.Result.ErrorCode == InvalidConnectionId; attempt++)
  {
      Thread.Sleep(TimeSpan.FromMilliseconds(100 * attempt));
      response = RegisterOnce(items);
  }

We now mirror the same pattern in `AsbClient::register_items_once`
followed by a retry loop in `register_items` — up to 5 attempts with
`100 * attempt` ms backoff.

Supporting changes:
- `RegisterItemsResponse` gains `result_code: Option<u32>` +
  `success: Option<bool>` so callers can read `Result.resultCodeField`
  + `successField` from the response. `decode_register_items_response`
  now tolerates an empty `<ASBIData />` Status array (server returns
  empty when the operation fails server-side) instead of erroring
  with `MissingField`. New helper `find_text_in_named_element` walks
  the body token stream.
- New public constant `RESULT_CODE_INVALID_CONNECTION_ID = 1` for
  callers that want to detect this status outside the retry path.
- The previously-failing test `decode_register_items_response_returns_
  missing_field_when_status_absent` was renamed and rewritten as
  `decode_register_items_response_returns_empty_status_when_absent`
  to match the new tolerant decode contract.

F31 closed. F30 (read-side dict-id resolution, landed in `eb6c689`)
was the unblocker — without it we couldn't see the
`<resultCodeField>1</>` element in the response and the failure mode
looked like a HMAC mismatch instead of a transient retryable error.

Workspace: 711 unit tests pass. Clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:02:38 -04:00
Joseph Doherty eb6c689f09 [M5] mxaccess-asb: F30 read-side dict-id resolution + matching .NET CV xmlns
**F30 (read side):** post-pass over `body_tokens` in `decode_envelope`
substitutes `NbfxName::Static(id)` → `NbfxName::Inline(name)` and
`NbfxText::DictionaryStatic(id)` → `NbfxText::Chars(name)` whenever
the dict id resolves. Lookup tries the per-message binary header
strings first (`(id-1)/2` slot), then falls back to the cumulative
session dynamic dict, then the `[MC-NBFS]` static table for even
ids. Tokens with unresolvable ids stay opaque so trace output still
reveals them.

This unblocks reading the live Register response: previously every
field came back as `<b:Static(43)>false</…>` and we couldn't tell
what the server actually said. Now we see `<b:successField>false</>`
and `<b:resultCodeField>1</>` clearly. resultCode 1 maps to
`AsbErrorCode.InvalidConnectionId` (`AsbResultMapping.cs:6`) —
which means AuthenticateMe failed silently and the server discarded
our connection state, even though the crypto stack is proven
byte-equal to .NET.

**Wire CV xmlns parity:** `<h:ConnectionValidator>` for the
`XmlSerializer` mode (AuthenticateMe / Disconnect / KeepAlive) now
emits all four xmlns declarations .NET writes, in the same order:
`xmlns:h`, default `xmlns` (same value), `xmlns:xsi`, `xmlns:xsd`.
.NET emits the default xmlns redundantly even though the `h` prefix
is bound to the same URL — captured against the .NET probe via
asb-relay. This was suspected to be the AuthenticateMe HMAC blocker
but the live test still returns `InvalidConnectionId`, so the bug
is elsewhere.

**F31 updated** with the surviving hypotheses for the
`InvalidConnectionId` mystery: server-side `XmlSerializer`
constructor mismatch, subtle byte-level wire difference affecting
deserialization, or unused `ServiceAuthenticationData` from the
ConnectResponse. Resolution probably requires server-side
instrumentation or controlled-scenario byte-level HMAC diff.

Workspace: 710 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:47:50 -04:00
Joseph Doherty 703c540bdc [M5] mxaccess-asb: MX_ASB_TRACE_REPLY trace + F30/F31 followups
Adds env-gated diagnostic trace `MX_ASB_TRACE_REPLY` that, on every
incoming SizedEnvelope, prints the raw reply bytes + decoded body
tokens (capped at 64) before any consumer-level decode runs. Used to
isolate the next blocker after F28's wire-format fixes landed: with
canonical XML signing, registry-driven DH params, dynamic-dict id
management, ConnectionValidator wire-format-per-action, chunked
ASBIData decode, and 0x0A `ShortDictionaryXmlnsAttribute` all in
place, AuthenticateMe is accepted by the server and a real
RegisterItemsResponse comes back — but it decodes to an opaque token
stream of `<b:Static(43)>false</b:Static(43)>` etc. because every
field name is dict-encoded against the response's own binary header
pre-pop and we never resolve those ids on the read side.

Two new follow-ups capture the remaining work:
- **F30**: resolve dict-id element/attribute names on the read side.
  Mirror the F28 write-side fix: read-side dynamic dict accumulates
  session strings via `intern`, and `decode_tokens` (or a post-pass)
  needs to substitute `NbfxName::Static(id)` with the resolved
  `NbfxName::Inline(name)` so downstream `find_element_named` /
  `collect_asbidata_payloads` match.
- **F31**: server response indicates `successField=false` with an
  empty Status array on Register. Hypotheses (in order): (a) silent
  HMAC mismatch despite F23 deterministic parity; (b) request-side
  wire-byte delta the server tolerates but interprets as 0 items;
  (c) tag does not resolve in the live Galaxy state. Resolution
  needs F30 first to read the actual Status array + error codes.

Workspace: 710 unit tests pass. Clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:35:29 -04:00
Joseph Doherty cf97eab396 [M5] mxaccess-asb: collect_asbidata_payloads concatenates chunked Bytes records
.NET's `XmlBinaryWriter.WriteBase64` chunks the byte array into
multiple consecutive NBFX `Bytes8/16/32` records when the total
exceeds the per-record budget. Live capture of a successful .NET
RegisterItemsResponse showed the Status ASBIData payload split into
`Bytes8(78) + Bytes8WithEndElement(1)` — total 79 bytes. Our decoder
walked tokens looking for a single `Text(Bytes(...))` after each
`<ASBIData>` element and stopped at the first chunk, returning a
truncated payload that hit `Codec(ShortRead)` when the consumer
tried to decode an ItemStatus from the partial bytes.

Fix: walk **all** consecutive `Text(Bytes)` tokens after `<ASBIData>`
and concatenate into a single payload before pushing to the result
vector. Mirrors WCF's reader behaviour, which reassembles the
chunks into one byte array via `XmlReader.ReadElementContentAsBase64`.

Workspace: 710 unit tests pass. Live state: AuthenticateMe is
accepted, RegisterItemsResponse decodes structurally — the remaining
"MissingField Status" error reflects a server-side semantic outcome
(server returned empty Status array) rather than a protocol bug,
likely tag-resolution related and outside F28's scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:36:38 -04:00
Joseph Doherty 104efc4e9b [M5] mxaccess-asb: F28 wire-format fixes — AuthenticateMe accepted live
Three wire-level bugs surfaced by side-by-side relay capture against
the .NET probe routed via the new --via flag:

1. **Dynamic-dictionary id drift**. Our `encode_envelope` hardcoded
   action_dict_id=1 / to_dict_id=3, which is correct for the FIRST
   message in a session but wrong for every subsequent one. The
   per-session dynamic dict accumulates across messages: Connect's
   binary header pre-pops [action,to] at ids 1,3; AuthenticateMe must
   reference the new action at id 5 (continuing the odd sequence) and
   the To URL at id 3 (still in the dict from Connect). Fix uses
   `DynamicDictionary::position_of` + `intern` to compute the right
   wire id, only pre-popping strings that are NEW to the session.
   Captured against .NET probe via asb-relay: AuthenticateMe binary
   header has only one string (action) at offset 0x260 (`06 de 08 2f
   2e ...`), and `<a:Action>` value `ab 05` references the new id 5.

2. **ConnectionValidator wire format depends on operation**. .NET's
   `IAsbDataV2` declares `[XmlSerializerFormat]` on AuthenticateMe,
   Disconnect, KeepAlive (one-way ops) — those use XmlSerializer for
   the ENTIRE message including the [MessageHeader] ConnectionValid-
   ator. Other ops use the default DataContractSerializer. The wire
   shapes differ:
     XmlSerializer: `<ConnectionId xmlns="http://asb.contracts.data/
       20111111">guid</ConnectionId>` (PascalCase property name in
       data namespace)
     DataContract: `<connectionIdField xmlns="http://schemas.data
       contract.org/2004/07/ArchestrAServices.ASBContract">guid</…>`
       (private "fooField" name in datacontract namespace)
   New `ValidatorWireFormat::for_action` picks the right shape per
   action; `encode_validator` now branches on it. New helpers
   `push_xml_text_field` / `push_xml_byte_array_field` for the
   XmlSerializer form. The DataContract form is preserved verbatim
   for Register/Read/Write/etc.

3. **Decoder missing 0x0A** (`ShortDictionaryXmlnsAttribute`). The
   server's RegisterItemsResponse uses `0x0A {dict-id}` to set the
   default namespace from the static dict; our decoder bailed out
   with `UnknownRecord(10)`. New decode arm produces a
   `DefaultNamespace` token with `DictionaryStatic` value.

**.NET probe gains a `--via` flag** (`AsbConnectionOptions.Via` →
`ChannelFactory.CreateChannel(addr, viaUri)`) so the probe can be
routed through asb-relay for byte-level capture without triggering
an `AddressFilterMismatch` fault. CoreWCF / .NET 10 dropped
`ClientViaBehavior`; the `CreateChannel(addr, via)` overload is the
modern equivalent.

Live status (this commit): Connect handshake works, AuthenticateMe
no longer faults (canonical XML + crypto + wire-format all match
.NET now), RegisterItemsResponse comes back from the server (a real
response, not a dispatcher fault). One remaining issue: our response
decoder hits `MissingField { field: "Status" }` — the server's
RegisterItemsResponse uses a slightly different element naming or
encoding than `collect_asbidata_payloads` expects. Next iteration
hunts that.

Workspace: 710 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:29:48 -04:00
Joseph Doherty ce27b63010 [M5] auth: deterministic HMAC fixture test rules out crypto stack
Adds end-to-end byte-equality test against a `.NET reference fixture
captured via the new `MxAsbClient.Probe --dump-deterministic-hmac`
flag. All inputs are pinned (passphrase, prime, generator, private-
key bytes, remote-pub bytes, message number, connection ID, AES IV,
consumer-data + IV bytes), so the test reproduces .NET's exact
output for every crypto step:

1. shared = remote_pub^private_key mod prime —  matches
2. crypto_key = shared || passphrase_utf8 —  matches
3. hmac = HMAC-SHA1(crypto_key, xml_utf8) —  matches
4. aes_key = PBKDF2-SHA1(base64(crypto_key), salt, 1000, 16) — 
5. encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7) — 

This conclusively rules out the entire crypto stack as the source of
the live AuthenticateMe `dispatcher/fault`. Our DH math, HMAC engine,
PBKDF2 derivation, AES-CBC PKCS7, and crypto_key concatenation are
byte-equal to .NET. The remaining live failure must come from one
of: (a) wire-level ConnectionValidator NBFX shape (DataContract field
names, mustUnderstand attribute, namespace), (b) WCF binary message
header (action+to dict pre-pop), or (c) a subtle XmlSerializer quirk
for live values that the hardcoded fixtures don't exercise (Guid
format edge case, base64 line wrapping, ulong text rendering).

Fixture lives at `crates/mxaccess-asb-nettcp/tests/fixtures/
deterministic-hmac/authenticate-me.kv` (KV format, ASCII hex, lines
trim CRLF/LF transparently). The companion `README.md` documents the
capture procedure and the per-step decomposition. The test consumes
the .NET-supplied canonical XML directly from the fixture's
`xml_utf8_b64` so a Rust XML emitter bug would not mask a Rust
crypto bug — XML byte-equality is verified separately by
`mxaccess-asb::xml_canonical::tests` against the `signed-xml/*.xml`
fixtures.

Workspace: 710 unit tests pass (was 709 + 1 new). Clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:12:17 -04:00
Joseph Doherty 42ac10a88f [M5] design: F28 follow-up update with progress + remaining blocker
Updates F28 with:
- Captured-fixtures section now lists all 6 shapes (added the
  empty-MAC variant) and 10 inferred XmlSerializer rules including
  the empty-byte-array → self-closing-element rule we discovered.
- New "Emitter landed" section pointing at commit `f14580e` and the
  five exposed `emit_*` functions, plus the
  `AsbAuthenticator::peek_next_message_number` plumbing.
- New "Registry-driven DH params" section explaining why
  `CryptoParameters::defaults()` was insufficient for live testing
  (live AVEVA installs use 768-bit primes; default is 1024-bit) and
  documenting the new MX_ASB_DH_* env-var contract.
- New "Remaining live blocker" section documenting that AuthenticateMe
  still faults despite canonical XML byte-equality and registry-correct
  DH params — most likely a byte-level HMAC/AES discrepancy that needs
  a deterministic-input unit-test triple to pin down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:38:23 -04:00
Joseph Doherty fd38189f43 [M5] auth+probe: env-gated crypto-key/AES-key trace for F28 follow-up
Adds diagnostic traces in both the Rust authenticator and the .NET
reference (under MX_ASB_TRACE_DERIVE / sharedTrace) that dump:
- crypto_key length + hex + base64 (shared || passphrase)
- derived AES key hex (PBKDF2-SHA1, 16 bytes)

Used to confirm during the F28 live-bring-up reconciliation that:
1. crypto_key passphrase suffix bytes [96..176] match between Rust and
   .NET — both read the same registry passphrase, both UTF-8-encode.
2. crypto_key shared_secret prefix bytes [0..96] DIFFER per run because
   each session has its own random DH private exponent. This is
   expected; what matters is the client+server agreement on the value
   for a single session, which the wire-tested DH math should produce
   given correct prime/generator/private-key handling.

Both traces are gated:
- Rust: `MX_ASB_TRACE_DERIVE=1` env var.
- .NET: `Action<string>? sharedTrace` field, populated when the
  authenticator is constructed with a non-null trace callback (the
  probe's `Console.WriteLine` shim wires this up by default).

Workspace: 709 tests still pass. No public-API changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:37:22 -04:00
Joseph Doherty f14580e0db [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>
2026-05-05 17:31:31 -04:00
Joseph Doherty dbb580b2c8 [M5] tools+fixtures: F28 canonical-XML signing target captured from .NET
Adds `MxAsbClient.Probe --dump-signed-xml` flag that builds five
ConnectedRequest shapes (AuthenticateMe, Disconnect, KeepAlive,
RegisterItemsRequest, UnregisterItemsRequest) with deterministic
field values and prints `AsbSerialization.ToXml(...)` output. The
output is exactly what `AsbSystemAuthenticator.Sign` HMACs
(`AsbSystemAuthenticator.cs:79`), so the Rust port's canonical-XML
emitter must produce byte-identical bytes for HMAC parity.

Captured fixtures land under
`rust/crates/mxaccess-asb/tests/fixtures/signed-xml/`:
- `authenticate-me.xml` — 1000 bytes
- `disconnect.xml` — 980 bytes
- `keep-alive.xml` — 705 bytes
- `register-items.xml` — 1068 bytes
- `unregister-items.xml` — 1072 bytes

Plus a `README.md` documenting 10 inferred XmlSerializer rules
(element name = class name not WrapperName, field order =
declaration order not [MessageBodyMember.Order], `[XmlType.Namespace]`
on field type causes per-child xmlns redeclaration on the children
not the wrapper, `*Specified` pattern controls Xxx emission, CRLF +
2-space indent + utf-16 declaration but UTF-8 bytes fed to HMAC).

`.gitattributes` marks the XML fixtures as binary (`*.xml -text`)
so neither `core.autocrlf` nor `text` filters can rewrite the byte
content — CRLF is part of the canonical form and must survive
round-trip through Git untouched.

`MxAsbClient.csproj` gains `<InternalsVisibleTo Include="MxAsbClient
.Probe" />` so the probe can reach the internal `AsbSerialization`
helper without making it public.

Workspace: 702 tests pass (no Rust changes — fixtures only).
F28 follow-up updated with the captured fixtures + the inferred rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:35:45 -04:00
Joseph Doherty d1e887b91b [M5] mxaccess-asb-nettcp/asb: Connect handshake live + SOAP fault detection
Live-bring-up reconciliation against AVEVA's MxDataProvider on Windows.
Connect now completes end-to-end (real DH key exchange, apollo:V2
encryption, ServicePublicKey/ServiceAuthenticationData populated). Five
fixes land:

1. NBFX `PrefixElement_a..z` (0x5E-0x77) and `PrefixAttribute_a..z`
   (0x26-0x3F) decode + encode arms. The server's ConnectResponse hit
   `0x65 = PrefixElement_h` for a dynamically-named element and our
   decoder bailed with `unknown NBFX record byte 0x65`. Both directions
   now round-trip; encoder picks short-form when prefix is a single
   lowercase ASCII letter.

2. xmlns redeclaration on `<Data>` AND `<InitializationVector>` inside
   `AuthenticationData` / `PublicKey`. `[XmlType(Namespace = ...)]` on
   AuthenticationData / PublicKey (`AsbContracts.cs:350-381`) means
   XmlSerializer emits `xmlns="..."` on each direct child. The default-
   ns scope ends at `</Data>`, so `<InitializationVector>` needs its own
   redeclaration to stay in the data namespace; without it the server
   fell back to messages-namespace and the deserialiser threw an
   `InternalServiceFault`.

3. SOAP-fault detection in `AsbClient::send_envelope`. New
   `ClientError::SoapFault { action, code, reason }` surfaces when the
   response Action header matches the canonical `dispatcher/fault`
   template; previously body decoders blindly ran and surfaced
   `MissingField { field: "Status" }` masking the actual fault. Reason
   text is extracted as the longest `NbfxText::Chars` in the body —
   robust against the `nbfs.rs` static-dictionary id mismatches.

4. Identified blocker (filed as F28): signed-request HMAC currently
   covers the NBFX wire bytes, but .NET's `AsbSystemAuthenticator.Sign`
   HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the canonical XML
   serialisation via `XmlSerializer` with namespace
   `urn:invensys.schemas` (`AsbSerialization.cs:12-48`). Until the Rust
   port emits identical XML bytes for `ConnectedRequest` subclasses,
   AuthenticateMe / RegisterItems / every signed RPC fault on the
   server. Connect itself is unsigned (`ServiceMessage` not
   `ConnectedRequest`) which is why it works today.

5. Identified `nbfs.rs` static-dictionary id drift (filed as F29): wire
   uses Fault=134/Code=142/Reason=144/Text=146/Value=154/Subcode=156
   but our table has them at 114/122/124/126/134/136. Off by 20 from
   id 114+ — 10 missing entries between `s` (id 112) and `Fault`. No
   request-side impact (we only encode IDs ≤44, all correct); the SOAP
   fault decode walks text records directly so it sidesteps the issue.

Workspace: 702 tests pass (no test count delta — wire-only fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:29:12 -04:00
Joseph Doherty 4c4177050c [M5] mxaccess-asb-nettcp/asb: xmlns raw-string + xsi/xsd on Body
WIRE-FORMAT BREAKTHROUGH — our envelope is now valid NBFX/WCF.
ConnectRequest reaches the server's operation handler. Direct-port-808
Connect now returns a server-side fault (operation invocation
error from the placeholder DH key) rather than TCP RST. With a real
DH key, asb-subscribe gets all the way to "response is missing
required field ServicePublicKey/Data" — meaning our Connect request
processed end-to-end, the server returned a ConnectResponse, and
the only remaining issue is in our response-body decoder.

Fixes:

1. **`XmlnsAttribute` (0x09) value is a RAW length-prefixed string,
   not a text record**. Per `[MC-NBFX]` §2.2.3, xmlns attribute
   values are `[length][bytes]`, NOT `[text-record-byte][value]`.
   Our F21 was emitting `aa <id>` for dict-static values which the
   receiver misparsed as a 0xAA-length string. Same fix applies to
   `ShortXmlnsAttribute` (0x08). Encoder now picks raw-string for
   `Chars` value, raw-int31 (via 0x0B) for `DictionaryStatic` value;
   decoder reads raw string in both code paths.

2. **xmlns:xsi + xmlns:xsd on `<s:Body>`**. WCF declares these
   namespaces on Body before opening the operation request element.
   Our envelope encoder now emits both as raw-string xmlns attrs
   right after `<s:Body>` opens. Required for `xsi:type` annotations
   that appear inside DataContract-serialised body fields.

Combined wire-byte impact (verified via asb-relay side-by-side
diff):

* All header bytes match .NET byte-for-byte through the entire
  `<s:Header>` section (Action / ConnectionValidator / MessageID
  via UniqueIdText / ReplyTo / To).
* `<s:Body>` xmlns:xsi + xmlns:xsd declarations match .NET.
* `<ConnectRequest>` opens identically.
* `<ConnectionId>` / `<ConsumerPublicKey>` / `<Data>` element names
  match.
* The only known remaining diff in the request: .NET emits
  `xmlns="http://asb.contracts.data/20111111"` on the inner
  `<Data>` element (the PublicKey class's XmlType namespace);
  we don't. Likely an issue but apparently non-fatal — the server
  processed our request successfully past this point.

Live status:
* Direct port-808 connect with real DH key: server returns
  "response is missing required field ServicePublicKey/Data" —
  meaning we sent a valid Connect, server replied with a
  ConnectResponse, but our decoder can't find the field. Next
  iteration is response-side decode work.

Workspace: 702 tests pass; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:57:31 -04:00
Joseph Doherty c2222b16b0 [M5] mxaccess-asb-nettcp/asb: F21 short forms + EndElement fix + UniqueIdText
Three NBFX-spec corrections discovered by diffing our wire output
against the .NET probe's capture:

1. **EndElement is 0x01, NOT 0x00**. Our F21 had this wrong since the
   first iteration. Our round-trip tests passed because encode and
   decode used the same wrong value, but interop with WCF's parser
   silently failed (TCP RST on every request). Fixed by changing
   `REC_END_ELEMENT` to 0x01 — all 702 tests pass on the new value.

2. **Single-letter prefix short forms**. WCF uses
   `PrefixDictionaryElement_<a-z>` (records 0x44-0x5D) and
   `PrefixDictionaryAttribute_<a-z>` (records 0x0C-0x25) for
   single-character prefixes. Our F21 always used the long forms
   (0x43 prefix-string + dict-id, etc.). The encoder now emits the
   short form when the prefix is a single ASCII lowercase letter; the
   decoder accepts both. New `prefix_letter_offset(prefix)` helper.

3. **`DictionaryXmlnsAttribute` (0x0B)** for xmlns:prefix declarations
   whose value is a static-dict id. The long form (0x09 +
   prefix-string + text-record) is still emitted when the value is an
   inline string, but for `xmlns:s="...soap-envelope"` (dict id 4) we
   now emit the short `0b 01 73 04` form WCF uses.

4. **UniqueIdText (0xAC)** added to `NbfxText` enum + encode/decode.
   WCF emits `<a:MessageID>` as a UniqueIdText carrying the 16 raw
   UUID bytes (NOT the `urn:uuid:...` text form). Updated
   `encode_envelope` to use this for MessageID.

Combined wire-byte impact: our envelope body section now matches the
.NET probe byte-for-byte through `<a:Action>`, `<h:ConnectionValidator>`,
`<a:MessageID>` (UniqueId), `<a:ReplyTo>`, `<a:To>`, and `<s:Body>`.
The trailing `01 01 01 01` = 4 EndElements is now the correct
record byte. Tests pass (702 total).

Live status: still TCP RST after the SizedEnvelope. Remaining
unknown is in the body section — the .NET capture shows xmlns:xsi /
xmlns:xsd declarations on the operation-specific request element
(ConnectRequest etc.) that we don't emit, plus possibly different
field encoding inside ConnectRequest. Next iteration will re-capture
through the relay and diff our body bytes against the new
.NET-byte-equivalent we now produce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:48:03 -04:00
Joseph Doherty 2867310817 [M5] mxaccess-asb: WCF binary message header (action+to dict pre-pop)
Adds the binary header block that WCF prepends to SizedEnvelope
payloads. Reverse-engineered from .NET probe wire bytes captured via
asb-relay.

Wire form (per the .NET capture analysis in the previous commit):
```
[outer length, multibyte-int31]
  [string-1 length, multibyte-int31] [UTF-8 bytes]   ← dict id 1 (action)
  [string-2 length, multibyte-int31] [UTF-8 bytes]   ← dict id 3 (to)
[NBFX <s:Envelope>...]
```

Inside the NBFX envelope, `<a:Action>` and `<a:To>` reference the
pre-pop strings via `DictionaryText 0xAA {odd-id}` instead of inlining
their values. The header strings get assigned odd dict ids
(1, 3, 5, ...); even ids stay reserved for the [MC-NBFS] static dict.

Encode side:
* `encode_envelope` now emits header [action, to] before NBFX. `to_uri`
  defaults to empty string when None — caller-supplied `with_to(uri)`
  is the supported path.
* AsbClient's `send_envelope` and `send_envelope_one_way` auto-fill
  `to_uri` from `self.via_uri` when not set.
* New private `encode_binary_header(strings)` helper.

Decode side:
* New `parse_binary_header_prefix(input)` heuristically detects + parses
  the header (look for plausible NBFX element record byte 0x40-0x77 at
  the offset implied by the outer length).
* New `resolve_with_header(text, dynamic, header)` resolves
  `DictionaryText` with odd id by indexing into header.strings; even
  ids fall through to static-dict lookup as before.

Tests pass (72) — round-trip envelope → bytes → envelope recovers
action through the new dict-id resolution path.

Live status: this commit gets us further but the connect SOAP
envelope still TCP-RSTs at SMSvcHost. The remaining delta vs the .NET
capture is structural NBFX optimisation: .NET uses single-letter
prefix-element/attribute records (0x44-0x77 PrefixDictionaryElement
_<a-z>, 0x0C-0x25 PrefixDictionaryAttribute_<a-z>, 0x0B
DictionaryXmlnsAttribute) while our F21 encoder always uses the long
forms (0x43 prefix-string + name-dict-id, etc.). Logically
equivalent but WCF's parser likely strict on which form it accepts.

Next iteration will add short-form encoding to F21 for single-letter
prefixes (s:, a:, h:, i:) which covers every namespace prefix in our
envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:40:59 -04:00
Joseph Doherty d4ee5f3a18 [M5] examples: asb-relay TCP middleman for live wire-byte capture
Listens on MX_RELAY_LISTEN (default 127.0.0.1:8088) and forwards to
MX_RELAY_UPSTREAM (default 127.0.0.1:808 — AVEVA's NetTcpPortSharing
SMSvcHost listener). Hex-dumps every byte both directions to stderr
with C->S / S->C tags + per-direction offset prefixes.

Usage:
  $env:MX_RELAY_LISTEN = '0.0.0.0:8088'
  .\rust\target\debug\examples\asb-relay.exe 2> relay.log
  # then in another shell:
  dotnet run --project src\MxAsbClient.Probe -c Release -- `
    '--endpoint=net.tcp://desktop-6jl3kko:8088/ASBService/Default_ZB_MxDataProvider/IDataV2'

Tested against the live AVEVA install on this box — captured a
620-byte client→server exchange including the full .NET probe's
preamble, SizedEnvelope, and End record. The capture surfaced one
critical missing piece in our wire format:

**WCF binary message framing prepends Action + To strings out-of-band**
before the actual NBFX SOAP envelope. The .NET probe's envelope
payload begins:

  74 27 [39 bytes "http://asb.contracts/20111111:connectIn"]    ← Action
  4b    [75 bytes "net.tcp://desktop-6jl3kko:8088/.../IDataV2"]  ← To
  56 02 ...                                                      ← <s:Envelope>

The 0x74 / 0x4b prefix bytes appear to be WCF-internal framing that
stores Action and To headers OUT of the SOAP envelope as a binary
optimization. Our F25 envelope encoder doesn't emit this — it goes
straight to `<s:Envelope>` (which the probe captured as `56 02 ...`
PrefixDictionaryElement_s + dict id 2). This is likely why the
server fault'd at AddressFilter mismatch in the previous iteration.

Note: when going through the relay, the .NET probe's `:8088` port
appears in the To URL inside the binary header, which doesn't match
the registered service URL on SMSvcHost — so this exact relay setup
returns the AddressFilterMismatch fault. The capture is still
valuable (we see what bytes WCF emits for our action/header
structure). For a fault-free dispatch, we'd need to:
* rewrite the binary header's port (0x4b length / URL bytes) at
  the relay, OR
* listen on port 808 directly (requires stopping SMSvcHost), OR
* run an admin-elevated Wireshark/Npcap loopback capture.

Cleanup: dotnet probe must use `--endpoint=URL` (single arg with `=`),
not space-separated; the probe's GetArg helper splits on `=`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:27:54 -04:00
Joseph Doherty 3b09297b27 [M5] live-probe iteration 1 — major wire-byte reconciliation fixes
First live-test cycle against AVEVA on this box. Comparing the .NET
probe's `--dump-messages` XML output against our NBFX-encoded
envelope surfaced six structural bugs in the F25 envelope/operations
layer. All fixed; tests passing (702 workspace).

Fixes (all backed by the .NET dump as ground truth):

1. **`mustUnderstand` attribute name** — NBFS dict id was 116
   (`MustUnderstand`, capital-M, a different SOAP token); SOAP 1.2
   spec uses lowercase `mustUnderstand` at id 0. Sending the wrong
   one triggered a WCF parse fault that surfaced as TCP RST.

2. **Missing `<a:MessageID>` header** — WCF's default binding
   requires MessageID for two-way operations. We now auto-generate
   `urn:uuid:<v4>` per envelope via a small inline `make_random_uuid_v4`
   helper (no `uuid` crate dep).

3. **Missing `<a:ReplyTo>` anonymous header** — WCF's
   BinaryMessageEncoder always emits `<a:ReplyTo><a:Address>...
   addressing/anonymous</a:Address></a:ReplyTo>` for two-way ops.

4. **ConnectionValidator field names + namespace** — we were
   emitting PascalCase `<ConnectionId>` etc. .NET's WCF
   DataContractSerializer uses the private backing-field names
   (`<connectionIdField xmlns="...ASBContract">guid</connectionIdField>`)
   per `[DataMember(Name = "fooField")]`. Added the
   `xmlns:i="...XMLSchema-instance"` declaration WCF emits
   alongside (even when no `i:nil` is used). Decoder now accepts
   both PascalCase (legacy tests) and DataContract field names.

5. **`<ASBIData>` over-wrapping** — we were emitting
   `<Items><ASBIData>{bytes}</ASBIData></Items>`. .NET's
   `AsbDataCustomSerializer.WriteStartObject` (`AsbContracts.cs:
   1561-1572`) REPLACES the field's outer element with `<ASBIData>`
   directly — there's no `<Items>` wrapper on the wire. Fixed by
   collapsing `BodyField::AsbiDataElement` to emit just `<ASBIData>`
   without the named outer element. The `name` field is retained
   for self-documentation.

6. **`collect_asbidata_payloads` API** — was keyed by field name
   (`Status` / `Values`); now positional (`payloads[0]`,
   `payloads.get(1)`) since the wrapper element is gone. All seven
   response decoders updated.

Plus tooling for the live-probe loop:
* `tools/Get-AsbPassphrase.ps1` — DPAPI loader that auto-discovers
  the solution name + reads the sharedsecret + decrypts it. Sets
  $env:MX_ASB_PASSPHRASE / MX_ASB_HOST / MX_ASB_VIA / MX_LIVE.
  Lowercase via-host (WCF SMSvcHost is case-sensitive on the URL
  host segment).
* `examples/asb-preamble-probe.rs` — diagnostic that connects,
  runs the preamble, captures the PreambleAck, then sends a
  synthetic ConnectRequest and dumps both directions as hex. Used
  to bisect the wire-byte deltas above.
* `examples/asb-subscribe.rs` port default fixed (5074 → 808 —
  WCF's NetTcpPortSharing/SMSvcHost listener confirmed via
  Get-NetTCPConnection).

**Status**: preamble + PreambleAck round-trip works end-to-end
against the live AVEVA install (verified via probe). The
post-preamble Connect SOAP envelope still gets TCP RST'd — the six
structural fixes above are necessary but not yet sufficient. Next
iteration needs binary wire capture (Wireshark + Npcap loopback,
or a TCP-relay middleman) to compare the .NET probe's BinaryMessageEncoder
output byte-for-byte with ours and find the remaining delta(s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:06:48 -04:00
Joseph Doherty 4ebfd8e3a3 [M5] tools: Get-AsbPassphrase.ps1 — DPAPI loader for live-probe env
Reads the ASB solution shared secret from the local Windows registry
(HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\
sharedsecret) and DPAPI-decrypts it with the canonical "wonderware"
entropy + LocalMachine scope, mirroring `AsbRegistry.cs:21-41`.

Auto-discovers:
  $env:MX_LIVE             = "1"
  $env:MX_ASB_HOST         = $env:COMPUTERNAME
  $env:MX_ASB_SOLUTION     = (read from DefaultASBSolution)
  $env:MX_ASB_GALAXY_NAME  = "ZB" (or -GalaxyName param)
  $env:MX_ASB_VIA          = net.tcp://<host>/ASBService/Default_<galaxy>_MxDataProvider/IDataV2
  $env:MX_ASB_PASSPHRASE   = (DPAPI-decrypted plaintext, never printed unless -Show)

Important wiring detail flagged inline: the system-wide ArchestrA
solution name (`Archestra_<HOST>`, source of the sharedsecret) is
DIFFERENT from the per-Galaxy MxDataProvider service segment
(`Default_<galaxy>_MxDataProvider`) that the WCF endpoint URL
targets. Both live under the same registry root but only the former
is owned by ArchestrA; the latter is what serves IASBIDataV2 per
the .NET probe's hardcoded default URL at
`src/MxAsbClient.Probe/Program.cs:5`.

Tested via dry-run on this box: `Archestra_DESKTOP-6JL3KKO` resolves
as the solution, 390 protected bytes decrypt to an 80-char
passphrase, and the assembled VIA URL matches the .NET probe's
default verbatim.

Hard rules:
* Plaintext passphrase NEVER printed unless -Show is explicit.
* Dot-source so env vars persist in the calling pwsh session.
* Caller account must be authorised against the LocalMachine-scope
  DPAPI blob (typically: any local Administrator).

Usage:
  . .\tools\Get-AsbPassphrase.ps1
  cargo run -p mxaccess --example asb-subscribe

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:45:43 -04:00
Joseph Doherty e3baeb8803 [M5] mxaccess: F26 step 3 — AsbSession high-level cheap-clone async API
Adds the public high-level entry point for the ASB transport.
Parallel to the NMX-shaped `Session` (rather than unified) because
NMX's `Session` carries CallbackExporter / callback router task /
recovery broadcast / INmxService2 mutex orchestration that has no
ASB analogue — and ASB's request/response loop over a single TCP
stream maps naturally to `Mutex<AsbClient>` that would be foreign
to NMX. Two paths converge at the consumer-facing API but stay
distinct at the orchestration layer.

Struct shape:
```rust
pub struct AsbSession { inner: Arc<AsbSessionInner> }
struct AsbSessionInner {
    transport: Mutex<AsbTransport<TcpStream>>,
    connect_response: ConnectResponse,
}
```

`Clone + Send + Sync` — clones share state through `Arc`, lock
serialises operations. Compile-time `assert_clone_send_sync` test
guards the contract.

API:
* `connect(endpoint, passphrase, crypto_parameters, via_uri,
  connection_id)` — full bring-up (TCP + preamble + DH handshake).
* `from_transport(transport, connect_response)` — build from an
  existing transport (tests, custom transports).
* `connect_response()` — surface the negotiated lifetime /
  Apollo flag.

Operation methods forward to AsbClient:
* `register_items` / `unregister_items` / `read` / `write`
* `keep_alive` / `disconnect`
* `create_subscription` / `add_monitored_items` / `publish` /
  `delete_monitored_items` / `delete_subscription`
* `publish_write_complete`

ClientError → mxaccess::Error mapping via
`ConnectionError::TransportFailure` (consistent with F26 step 2).

1 new test:
* `asb_session_is_clone_send_sync` — compile-time trait-bound
  assertion.

Workspace: 702 tests pass.

Stubbed for next F26 iteration:
* `Stream<Item = MonitoredItemValue>` subscription handle that
  internally drives a publish-loop. Today consumers loop
  `publish().await` themselves.
* Recovery / reconnect policy — needs a captured ASB-side
  disconnect to inform the retry strategy.
* Live-probe wire-byte reconciliation against the WCF DataContract
  XML serializer's actual output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:23:59 -04:00
Joseph Doherty 9876b4ebb4 [M5] mxaccess-asb: F25 step 10 — PublishWriteComplete + DeleteMonitoredItems
Closes out the F25 operation matrix. AsbClient now wraps every
IASBIDataV2 operation:

  Lifecycle:    connect / disconnect / send_end / send_preamble / keep_alive
  Items:        register_items / unregister_items / read / write
  Subscriptions:create_subscription / add_monitored_items / publish
                / delete_monitored_items / delete_subscription
  Write cb:     publish_write_complete

API additions:
* `build_publish_write_complete_request_body()` — empty wrapper
  per `AsbContracts.cs:204-205`. No body fields beyond inherited
  ConnectionValidator.
* `decode_publish_write_complete_response` — returns count of
  `<ItemWriteComplete>` elements observed. Per-element decode
  (Status + WriteHandle) deferred to a later iteration since
  ItemWriteComplete is regular WCF DataContract rather than the
  binary fast-path.
* `build_delete_monitored_items_request_body` — same MonitoredItem
  shape as AddMonitoredItems but omits RequireId per `cs:268-277`.
* `decode_delete_monitored_items_response` — per-item Status array.
* Client wrappers: `publish_write_complete()`,
  `delete_monitored_items(subscription_id, items)`.

6 new tests:
* `publish_write_complete_body_is_empty_wrapper` — body shape.
* `publish_write_complete_response_counts_item_write_complete_elements`
  — counts 2 / 0 elements correctly.
* `publish_write_complete_response_zero_when_no_callbacks`.
* `delete_monitored_items_body_carries_subscription_id_and_items`.
* `delete_monitored_items_body_omits_require_id_field`.
* `delete_monitored_items_response_round_trip`.

Workspace: 701 tests pass (was 695, +6).

Stubbed for future iterations:
* ItemWriteComplete per-element decode (Status + WriteHandle) once
  a live capture confirms the WCF DataContract XML wire form.
* Optional MonitoredItem fields (Active / TimeDeadband /
  ValueDeadband / UserData) — same wire-byte uncertainty.
* Optional WriteValue fields (Comment / Timestamp / etc.).

All wire-byte caveats trace back to live-probe reconciliation
against an actual AVEVA VM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:17:01 -04:00
Joseph Doherty 0441a2e693 [M5] mxaccess-asb: F25 step 9 — Write operation
Closes the highest-value remaining IASBIDataV2 op. With Write landed,
the read+write+subscribe path is functionally complete in-memory.

API additions:
* `MinimalWriteValue { value: AsbVariant }` — carries just the Value
  payload. Optional ArrayElementIndex / Comment / HasQT / Status /
  Timestamp fields are deferred to a later iteration once a live
  capture confirms the WCF DataContract XML form.
* `build_write_request_body(items, values, write_handle)` per
  `AsbContracts.cs:181-194`:
  ```xml
  <WriteBasicRequest xmlns="urn:msg.data.asb.iom:2">
    <Items><ASBIData>{ItemIdentity[] binary}</ASBIData></Items>
    <Values>
      <WriteValue><Value><ASBIData>{Variant binary}</ASBIData></Value></WriteValue>
      ...
    </Values>
    <WriteHandle>{i32}</WriteHandle>
  </WriteBasicRequest>
  ```
  Items array uses the IAsbCustomSerializableType binary fast-path;
  each Value's inner Variant also uses the fast-path. WriteHandle is
  an Int32 (opaque correlation echoed in PublishWriteComplete).
* `decode_write_response` — per-item Status array (mirrors the
  unregister/register pattern).
* `AsbClient::write(items, values, write_handle)` — thin wrapper.

4 new tests:
* `write_request_body_carries_items_values_and_write_handle` — body
  shape sanity (WriteHandle = 7 Int32, WriteValue element present).
* `write_request_body_pairs_items_and_values_arrays` — 2 items + 2
  values produces 2 WriteValue elements.
* `write_response_round_trips_status_array` — Status decode.
* `write_response_missing_status_fails` — graceful MissingField
  error.

Workspace: 695 tests pass (was 691, +4).

Stubbed for next F25 iterations:
* `PublishWriteComplete` — empty request, `ItemWriteComplete[]`
  response.
* `DeleteMonitoredItems` — mirrors AddMonitoredItems pattern.
* Optional WriteValue fields (Comment / Timestamp / etc.) once a
  live capture confirms the wire-byte layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:04:11 -04:00
Joseph Doherty b543eb1f84 [M5] mxaccess-asb: F25 step 8 — subscription operations
CreateSubscription / AddMonitoredItems / Publish / DeleteSubscription.
Completes the IASBIDataV2 read-and-subscribe path; remaining ops
(Write/PublishWriteComplete/DeleteMonitoredItems) are mechanical
extensions of the same pattern.

Contracts:
* `MonitoredItemValue` codec (IAsbCustomSerializableType binary
  fast-path: ItemIdentity + RuntimeValue + AsbVariant per
  `AsbContracts.cs:1064-1068`) with array codec (4-byte int32
  count + per-element body, mirrors `WriteArrayToStream` at
  `cs:1095-1103`).

Request builders:
* `build_create_subscription_request_body(max_queue_size,
  sample_interval)` — primitive fields per `cs:215-223`.
* `build_delete_subscription_request_body(subscription_id)` —
  primitive field per `cs:232-237`.
* `build_publish_request_body(subscription_id)` — primitive field
  per `cs:287-292`.
* `build_add_monitored_items_request_body(subscription_id, items,
  require_id)` — minimal MonitoredItem shape (Item +
  SampleInterval + Buffered). Full optional-field set
  (Active/TimeDeadband/ValueDeadband/UserData) deferred to a later
  iteration once a live capture confirms the WCF DataContract
  XML wire form.

Response decoders:
* `decode_create_subscription_response` — single int64
  SubscriptionId field. Decoder accepts Int64Text, Int32Text,
  Zero/One, or numeric-string Chars (covers all WCF binary
  numeric encodings).
* `decode_add_monitored_items_response` — Status array +
  ItemCapabilities-presence flag (mirrors RegisterItemsResponse).
* `decode_publish_response` — Status array + Values
  (MonitoredItemValue) array.

`BodyField::Int64Element` variant added for the primitive
SubscriptionId / MaxQueueSize / SampleInterval fields. `uint64`
helper casts to i64 (covers proven value range; if ulong > i64::MAX
ever appears we'll add UInt64Text to F21's NbfxText enum).

Client wrappers (4 new methods on AsbClient):
* `create_subscription(max_queue_size, sample_interval)`
* `add_monitored_items(subscription_id, items, require_id)`
* `publish(subscription_id)`
* `delete_subscription(subscription_id)`

11 new tests cover:
* MonitoredItemValue round-trip + array round-trip.
* CreateSubscription request body shape (Int64 payloads).
* CreateSubscription response decoder via Int64Text.
* CreateSubscription response decoder via Chars text fallback.
* CreateSubscription response missing-field error.
* AddMonitoredItems body carries SubscriptionId + MonitoredItem
  elements.
* AddMonitoredItems response Status round-trip.
* DeleteSubscription body carries SubscriptionId.
* Publish request body shape.
* Publish response Status + Values round-trip.

Workspace: 691 tests pass (was 680, +11). The asb-subscribe example
can now do create_subscription → add_monitored_items → publish-loop
→ delete_subscription once wire-byte reconciliation against a live
capture confirms the MonitoredItem XML shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:57:59 -04:00
Joseph Doherty c6570dcd06 [M5] mxaccess: asb-subscribe example exercises full F25+F26 stack
Replaces the M5 placeholder with an actual end-to-end demo:

  AsbTransport::connect (TCP + preamble + DH handshake)
  → register_items
  → read
  → disconnect
  → send_end

Until F25 subscription ops (CreateSubscription / AddMonitoredItems
/ Publish-callback) land, the example is a Read-loop demo. Once
subscription ops arrive, it gains a Publish-loop and lives up to
its name.

Env vars (analogous to the NMX `connect-write-read` example):
  MX_LIVE — non-empty enables the live path
  MX_ASB_HOST — endpoint host[:port]; defaults port 5074
  MX_ASB_PASSPHRASE — solution shared secret
  MX_ASB_VIA — `net.tcp://...` URI (optional; derived from MX_ASB_HOST
    when omitted)
  MX_TEST_TAG — tag reference (default `TestChildObject.TestInt`)

Without MX_LIVE: prints the `Setup-LiveProbeEnv.ps1` hint and exits
cleanly with status 0 — the same pattern every other live example
follows.

Connection-id is a fresh 16-byte random buffer (matches .NET's
`Guid.NewGuid()` at `MxAsbDataClient.cs:36`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:34:24 -04:00
Joseph Doherty 14bb5297a8 [M5] mxaccess: F26 step 2 — AsbTransport::connect TCP+preamble+handshake
Adds the `tokio::net::TcpStream`-specialised async constructor that
owns the full transport-bring-up sequence:

  TCP connect → NMF preamble → DH Connect → AuthenticateMe (one-way)

Signature:
```
async fn connect(
    endpoint: SocketAddr,
    passphrase: &str,
    crypto_parameters: &CryptoParameters,
    via_uri: impl Into<String>,
    connection_id: [u8; 16],
) -> Result<(AsbTransport<TcpStream>, ConnectResponse), Error>
```

Returns the `ConnectResponse` alongside the transport so callers can
inspect the negotiated `connection_lifetime` (the `:V2` suffix
toggles Apollo vs Baktun encryption — see F23).

New error variant: `ConnectionError::TransportFailure { detail }`
covers all transport-bring-up failure modes (NMF / NBFX / auth /
peer Fault). The underlying error type is intentionally erased to
keep the public taxonomy small; `detail` carries the Display
representation.

Errors are mapped at the AsbClient / AuthError boundary via private
`map_client_error` / `map_auth_error` helpers.

1 new test:
* `connect_to_unreachable_endpoint_surfaces_connection_error` — TCP
  connect to 127.0.0.1:1 (TCPMUX-reserved) cleanly errors without
  panicking. Smoke test for the constructor signature + error path.

Stubbed for F26 step 3:
* `Session::connect_asb` constructor — the SessionInner refactor to
  host both NMX + ASB transports under one struct is heavier than
  this iteration's scope.
* Operation-routing layer that maps ASB result types (ItemStatus,
  RuntimeValue) back to mxaccess types (MxStatus, DataChange,
  MxValue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:14:16 -04:00
Joseph Doherty 8a0f92b6bc [M5] mxaccess: F26 step 1 — AsbTransport bridges AsbClient into Transport trait
First slice of F26. Bridges F25's working AsbClient into the M0
`mxaccess::Transport` trait that Session uses to discriminate
operations across NMX and ASB transports.

API additions:
* `mxaccess::AsbTransport<T>` — generic over the same
  AsyncRead+AsyncWrite+Unpin+Send+Sync+'static bound that AsbClient
  takes. Owns an AsbClient and exposes it via `client_mut()` /
  `into_client()`.
* `impl Transport for AsbTransport<T>`:
  - `capabilities()` — `buffered_subscribe = false`,
    `activate_suspend = false`, `operation_complete_frame = false`
    per `design/60-roadmap.md` M5 (no NMX-specific extensions on
    ASB).
  - `kind()` — `TransportKind::Asb`.

Path-dep wiring: `mxaccess` now imports `mxaccess-asb` +
`mxaccess-asb-nettcp` directly.

Compile-time `Send + Sync + 'static` assertion guards the
trait-bound contract.

2 new tests:
* `asb_transport_kind_is_asb`.
* `asb_transport_capabilities_disable_buffered_and_activate_suspend`.

Stubbed for F26 step 2:
* `Session::connect_asb` constructor that owns TCP open +
  preamble + DH handshake orchestration.
* Operation routing that maps ASB types (ItemStatus, RuntimeValue)
  back to mxaccess types (MxStatus, DataChange, MxValue).

Stubbed for F26 step 3:
* Subscription routing — Session::subscribe on ASB needs F25
  subscription operations (CreateSubscription / AddMonitoredItems
  / Publish), which are not yet implemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:57:20 -04:00
Joseph Doherty 1b1ee1e0b7 [M5] mxaccess-asb: F25 step 7 — Disconnect closes the session lifecycle
Mirrors `AsbContracts.cs:109-114` — same payload shape as
AuthenticateMe (Data + InitializationVector under
ConsumerAuthenticationData) but under the `<DisconnectRequest>`
wrapper. Sent one-way + signed (regular HMAC, no force) per
`AsbContracts.cs:22` (`IsOneWay = true`).

API additions:
* `build_disconnect_request_body(data, iv)` — NBFX token stream for
  the DisconnectRequest body.
* `AsbClient::disconnect()` — builds a fresh encrypted
  authentication-data blob via F23's `create_authentication_data()`
  (encrypts `local_pub || remote_pub` under the derived AES key
  with a fresh IV), wraps it in a DisconnectRequest, sends one-way
  signed.

2 new tests:
* `disconnect_request_carries_data_and_iv_under_correct_wrapper` —
  outer element name + Data/IV byte-payload order.
* `disconnect_writes_signed_one_way_envelope` — end-to-end via
  `tokio::io::duplex` peer; verifies the SizedEnvelope payload
  contains the `:disconnectIn` action string.

With Disconnect landed, AsbClient now covers the full session
lifecycle:
  send_preamble → connect → register_items / read / keep_alive
  / unregister_items → disconnect → send_end → stream shutdown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:51:39 -04:00
Joseph Doherty 321b7963a4 [M5] mxaccess-asb: F25 step 6 — Connect/AuthenticateMe handshake
Critical-path piece that turns a fresh TCP stream into an
authenticated session. With this slice landed, an `AsbClient` can
now do `send_preamble().await? -> connect().await? -> register_items()`
end-to-end against a peer.

Operations API additions:
* `build_connect_request_body(connection_id, public_key)` — first op
  on a fresh session. **Unsigned** (no ConnectionValidator header)
  because the authenticator hasn't received the service key yet.
  Wire shape: `<ConnectRequest xmlns="…messages/20111111">
    <ConnectionId>{guid-text}</ConnectionId>
    <ConsumerPublicKey><Data>{pubkey-bytes}</Data></ConsumerPublicKey>
  </ConnectRequest>` per `AsbContracts.cs:78-86`.
* `build_authenticate_me_request_body(data, iv)` — second op,
  **one-way + signed with `forceHmac=true`** per `MxAsbDataClient.cs
  :106-111`. Carries the encrypted `local_pub || remote_pub` blob
  produced by F23's `create_authentication_data()`.
* `ConnectResponse { service_public_key, service_authentication_data,
  connection_lifetime }` + `AuthenticationDataBytes { data, iv }`.
* `decode_connect_response(body, dict)` — extracts ServicePublicKey
  (required), optional ServiceAuthenticationData, optional
  ConnectionLifetime. The lifetime's `:V2` suffix is what F23
  inspects to toggle Apollo (raw AES) vs Baktun (deflate-then-AES)
  encryption.

Client API addition:
* `AsbClient::connect()` — orchestrates the full handshake:
  1. Build + send ConnectRequest (unsigned) carrying our DH public
     key + connection-id GUID.
  2. Decode ConnectResponse.
  3. `authenticator.accept_connect_response(...)` — feeds the
     service public key + lifetime into F23 so it derives the
     shared secret and picks Apollo/Baktun.
  4. `authenticator.create_authentication_data()` — encrypts
     `local_pub || remote_pub` under the derived AES key.
  5. Send AuthenticateMeRequest (one-way, signed with HMAC-SHA1
     forced).
  Returns the `ConnectResponse` so callers can inspect the
  negotiated connection lifetime.

6 new tests:
* ConnectRequest carries hyphenated GUID + raw public-key bytes.
* AuthenticateMe carries Data + IV bytes in order.
* ConnectResponse round-trip with all optional fields populated.
* ConnectResponse round-trip without optional fields.
* ConnectResponse decoder surfaces MissingField when
  ServicePublicKey is absent.
* End-to-end client::connect handshake via `tokio::io::duplex`
  peer that synthesises a ConnectResponse using bob's public key
  (so DH shared-secret derivation actually works) and drains the
  AuthenticateMe one-way SizedEnvelope.

Wire-byte caveat documented inline: WCF XML serialization may add
`xsi:type` attributes / distinct namespaces around <PublicKey> /
<AuthenticationData>; this builder ships the simplest plausible
shape and the live-probe iteration will reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:47:35 -04:00
Joseph Doherty 9b8133f725 [M5] mxaccess-asb: F25 step 5 — KeepAlive + Read + one-way client ops
Extends AsbClient with one-way operation support (`IsOneWay = true`
in IASBIDataV2) plus the KeepAlive and Read operations.

Client API additions:
* `send_envelope_one_way(env)` — frames in SizedEnvelope, writes,
  returns immediately. No response read. Mirrors WCF's IsOneWay
  semantics for KeepAlive / Disconnect / AuthenticateMe.
* `send_signed_envelope_one_way(action, body, force_hmac)` —
  one-way variant that runs the body through F23's authenticator
  signing path so the ConnectionValidator header is attached.
* `keep_alive()` — sends an empty `KeepAliveRequest` with default
  signing. Used to keep the channel alive past the WCF inactivity
  timeout (30s default at `MxAsbDataClient.cs:683`).
* `read(items)` — sends a signed Read envelope, decodes
  ReadResponse with both Status and Values arrays.

Operations API additions:
* `build_keep_alive_request_body()` — empty wrapper element +
  asb.contracts.messages namespace. Mirror of `AsbContracts.cs:117`
  (`public sealed class KeepAlive : ConnectedRequest;`).
* `ReadResponse { status: Vec<ItemStatus>, values: Vec<RuntimeValue> }`
  per `AsbContracts.cs:169-179`.
* `decode_read_response(body_tokens)` — pulls both ASBIData
  payloads, decodes Status as ItemStatus[], decodes Values via
  `decode_runtime_value_array` (4-byte int32 count + per-element
  `RuntimeValue::decode` from F24).

5 new tests:
* KeepAlive body shape (empty wrapper, correct namespace).
* ReadResponse decoder round-trip with both Status and Values.
* ReadResponse decoder graceful handling when Values is absent
  (returns empty vec).
* End-to-end client::keep_alive — peer drains SizedEnvelope but
  doesn't respond; client returns Ok().
* End-to-end client::read — peer responds with synthetic
  ReadResponse, client recovers Values[0].timestamp_binary == 1234
  and Values[0].status round-trip.

Stubbed for next F25 iterations:
* AsbClient::connect — DH Connect + AuthenticateMe handshake. Needs
  ConnectRequest / ConnectResponse builders (regular WCF XML, not
  the IAsbCustomSerializableType fast-path).
* Write / PublishWriteComplete / CreateSubscription /
  AddMonitoredItems / Publish / Disconnect operation wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:42:39 -04:00
Joseph Doherty 1e59249662 [M5] mxaccess-asb: F25 step 4 — AsbClient async network loop
The first slice of F25 that actually moves bytes across a transport.
Wraps every M5 framing layer (F19-F25.3) into a single async client
generic over `AsyncRead + AsyncWrite + Unpin + Send`. Tested in-memory
via `tokio::io::duplex` — no live ASB endpoint required.

API:
* `AsbClient::new(stream, authenticator, via_uri)` — wraps a Tokio
  transport + F23 authenticator into a ready client.
* `send_preamble()` — writes the canonical preamble (Version 1.0 →
  Duplex → Via → BinaryWithDictionary → PreambleEnd) and reads the
  peer's PreambleAck. Surfaces Fault as `ClientError::Fault(msg)`.
* `send_envelope(env)` — frames `SoapEnvelope` in a SizedEnvelope NMF
  record, writes, reads the response SizedEnvelope, decodes back to
  `DecodedEnvelope`.
* `send_signed_envelope(action, body, force_hmac)` — calls F23
  authenticator's `sign` on the unsigned body bytes, attaches a
  ConnectionValidator header (base64'd MAC + IV), sends.
* `register_items` / `unregister_items` — thin per-operation wrappers
  threading body builder + response decoder.
* `send_end()` — writes record 0x07 + shutdowns the stream.

Async record reader: streaming decode of the multibyte-int31 length
prefix for SizedEnvelope (0x06) / Fault (0x08), plus a fallback path
for Version / Mode / KnownEncoding / etc.

`ClientError` covers I/O, NMF, NBFX, Envelope, Operation, Auth, plus
PreambleNotSent / AlreadyClosed / Fault / PeerClosed /
UnexpectedRecord guards.

6 new tests via in-memory `tokio::io::duplex`:
* Preamble round-trip with synthetic peer returning PreambleAck.
* Fault propagation through preamble exchange.
* End-to-end RegisterItems request → response with a peer that
  drains preamble, replies PreambleAck, drains the SizedEnvelope,
  responds with a synthetic RegisterItemsResponse body containing a
  binary-encoded ItemStatus array. Client decodes and asserts the
  recovered ItemIdentity name.
* `send_envelope` before preamble fails with PreambleNotSent.
* `send_end` writes record 0x07 to the wire.
* PreambleMode re-export keeps shape parity with `nmf::NmfMode`.

Known limitation: the signing path currently hashes the NBFX-encoded
body; .NET hashes the XML-text `request.ToXml()`. Functionally
present (validator built and attached) but MAC bytes won't match
.NET's MAC for the same payload until the live-probe iteration
reconciles which canonical form to sign.

Stubbed for next F25 iteration:
* `AsbClient::connect` — DH `Connect` + `AuthenticateMe` handshake
  flow. Needs ConnectRequest/Response builders (regular WCF XML, not
  the IAsbCustomSerializableType fast-path) and the
  `AsbAuthenticator::create_authentication_data` integration.
* Read / Write / Subscription operation wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:37:48 -04:00
Joseph Doherty c4bf0a0a04 [M5] mxaccess-asb: F25 step 3 — response decoders + Read request body
Foundation for response decoding. Adds:

* `contracts::ItemStatus` — ports `AsbContracts.cs:639-722`. Wire
  layout matches `WriteToStream` exactly: Item (ItemIdentity binary)
  → Status (AsbStatus binary, from F24) → ErrorCode (u16) →
  ErrorCodeSpecified (u8 bool). Note this is NOT the DataMember
  declaration order — the binary serialiser hand-picks Item-first.

* `encode_item_status_array` / `decode_item_status_array` — same
  4-byte int32 count + per-element WriteToStream pattern as the
  ItemIdentity array codec.

* `operations::collect_asbidata_payloads(tokens, field_name)` — walks
  an NBFX token stream and pulls out `<{field}><ASBIData>{Bytes}
  </ASBIData></{field}>` payload bytes. Returns Vec<Vec<u8>> because
  some response shapes (ReadResponse) carry multiple ASBIData
  payloads (Status + Values).

* `decode_register_items_response` / `decode_unregister_items_response`
  — parse SOAP body NBFX tokens into typed RegisterItemsResponse /
  UnregisterItemsResponse. The optional ItemCapabilities array (XML-
  serialised, not binary) is recorded as a presence flag for now;
  decoding the individual ItemRegistration records is a follow-up.

* `build_read_request_body(items)` — simplest unary IASBIDataV2
  request, just `<ReadRequest xmlns="..."><Items><ASBIData>...
  </ASBIData></Items></ReadRequest>`.

* `OperationError` — typed error for response-decode failures
  (`MissingField { field }` and codec wraps).

9 new tests: ItemStatus round-trip (default + with id + with status
payload), ItemStatus array round-trip, RegisterItemsResponse
round-trip via synthetic body, ItemCapabilities presence detection,
UnregisterItemsResponse round-trip, multi-payload extraction (ReadResponse-
shape Status + Values), Read body shape correctness, MissingField
error when Status is absent.

Stubbed for next F25 iteration: Write / PublishWriteComplete /
CreateSubscription / AddMonitoredItems / DeleteMonitoredItems /
Publish builders, ReadResponse + WriteResponse decoders (need
WriteValue / RuntimeValue contract codecs), and the AsbClient
network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:32:36 -04:00
Joseph Doherty a2b8989cbf [M5] mxaccess-asb: F25 step 2 — per-operation request body codecs
Adds the IAsbCustomSerializableType binary fast-path + per-operation
request-body NBFX-token builders. RegisterItems and UnregisterItems
now compose end-to-end through SoapEnvelope + encode_envelope to a
byte stream that round-trips back to the original ItemIdentity array.

Three pieces:

1. F21 NBFX gains `Bytes8/16/32` text records (records 0x9E/0xA0/0xA2
   plus +1 WithEndElement variants). WCF's `XmlDictionaryWriter.
   WriteBase64` emits these in binary form — not actual base64 text —
   so they're required for the `<ASBIData>` content.

2. `mxaccess-asb::contracts::ItemIdentity` ports `AsbContracts.cs:533-633`:
   * Wire layout: u16 kind + u16 reference_type +
     AsbBinary.WriteUnicodeString(Name) + AsbBinary.WriteUnicodeString
     (ContextName) + u64 Id + u8 IdSpecified.
   * `AsbBinary.WriteUnicodeString` per cs:1622-1633: u32 byte-length
     + UTF-16LE bytes; null/empty collapse to a 4-byte zero header.
   * `encode_item_identity_array` / `decode_item_identity_array`
     mirror `WriteArrayToStream` — 4-byte int32 count + each
     element's `WriteToStream` output. Per `AsbDataCustomSerializer`
     at cs:1583-1591.
   * `absolute_by_name(...)` convenience constructor matching
     `MxAsbDataClient.CreateAbsoluteItem` at cs:172-194.

3. `mxaccess-asb::operations` builds SOAP body NBFX token streams:
   * `build_register_items_request_body(items, require_id, register_only)`
     — RegisterItems contract per cs:119-143.
   * `build_unregister_items_request_body(items)` — UnregisterItems
     per cs:145-159.
   * Internal `BodyField` helper assembles the wire shape:
     `<RegisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
        <Items><ASBIData>{Bytes(payload)}</ASBIData></Items>
        <RequireId>true|false</RequireId>
        <RegisterOnly>true|false</RegisterOnly>
      </RegisterItemsRequest>`

15 new tests cover:
* ItemIdentity round-trip (default, with id, unicode name).
* AsbBinary unicode-string null/empty/value semantics.
* Byte-layout pinning (21 bytes for default ItemIdentity, le-int32
  array count).
* ItemIdentity array round-trip.
* `<ASBIData>` Bytes record round-trip across NBFX widths
  (Bytes8/16/32 selected by length).
* RegisterItems body → SoapEnvelope → encode → decode → recover the
  ItemIdentity array end-to-end.
* RequireId / RegisterOnly Bool wire form.
* UnregisterItems body uses correct outer element name and omits
  the RegisterItems-only fields.

Stubbed for next F25 iteration: per-operation Read / Write /
PublishWriteComplete / CreateSubscription / AddMonitoredItems /
DeleteMonitoredItems / Publish builders, response decoders, and the
`AsbClient` network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:24:19 -04:00
Joseph Doherty 25dbd8d3bd [M5] mxaccess-asb: F25 step 1 — SOAP envelope codec
First slice of F25. Provides the building blocks the per-operation
request/response codecs and the network loop will compose:

* `actions` module — IASBIDataV2 action strings (all 14 operations,
  verbatim from `AsbContracts.cs:14-58`).
* `ConnectionValidator` — SOAP header struct mirroring
  `AsbContracts.cs:65-117`. `from_signed(&SignedValidator)` converts
  F23's MAC + IV to base64 for the wire, matching .NET's
  `BinaryWriter`-via-`XmlSerializer` shape.
* `SoapEnvelope` + `encode_envelope` — assembles the NBFX token
  stream: `s:Envelope` → `s:Header` → `a:Action s:mustUnderstand="1"`
  → optional `h:ConnectionValidator` → `s:Body` → caller-supplied
  body tokens. Uses static-dictionary IDs for the SOAP/WS-Addressing
  tokens via F22's `lookup_static`.
* `decode_envelope` — pulls action + validator + body tokens back
  out of received bytes. Tolerant of header ordering.
* Mixed-endian GUID format/parse (`format_uuid` / `parse_uuid`) that
  mirrors .NET's `Guid.ToString("D")` byte order so connection-id
  round-trip matches the wire exactly.

9 new unit tests cover:
* Round-trip with and without validator.
* `from_signed` base64 encoding of MAC + IV.
* `format_uuid` produces the correct .NET-mixed-endian hex string.
* GUID round-trip through string formatter.
* Action string presence in the encoded byte stream.
* Decoder tolerance of envelopes without an Action header.
* Validator round-trip through full encode → decode.
* Lint-style guard that all 14 action constants are URIs ending `In`.

Stubbed for next F25 iteration: per-operation request/response
struct codecs (`ConnectRequest`, `RegisterItemsRequest`, etc.) +
`AsbClient` network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:16:22 -04:00
Joseph Doherty 5f985588f7 [M5] mxaccess-asb-nettcp: F21 [MC-NBFX] binary XML token codec
Ports the proven subset of `[MC-NBFX]` to `mxaccess-asb-nettcp::nbfx`.

Token model: Element { prefix, name } / EndElement / Attribute /
DefaultNamespace / NamespaceDeclaration / Text. Element + attribute
names can be inline UTF-8, an `[MC-NBFS]` static-dictionary id (via
F22's `lookup_static`), or a per-session `DynamicDictionary` id.

Text records covered: Empty (0xA8), Zero (0x80), One (0x82), Bool
(0x84/0x86 + 0xB4), Int8 (0x88), Int16 (0x8A), Int32 (0x8C), Int64
(0x8E), Chars (0x98/0x9A/0x9C — width variant chosen automatically by
payload length), DictionaryText (0xAA — both static and dynamic refs).

`*WithEndElement` collapse is automatic: a `Text → EndElement` pair
encodes as the `+1` record byte (e.g. `EmptyTextWithEndElement = 0xA9`,
`TrueTextWithEndElement = 0x87`). The decoder splits the implicit
EndElement back out so consumers see the same token stream regardless
of which wire form was used.

Element variants covered: ShortElement (0x40), Element (0x41 with
prefix string), ShortDictionaryElement (0x42), DictionaryElement
(0x43). Prefix-letter family (0x44-0x77) deferred — emit the long
form for now.

Attribute variants covered: ShortAttribute (0x04), Attribute (0x05),
ShortDictionaryAttribute (0x06), DictionaryAttribute (0x07), plus
xmlns variants (0x08/0x09).

15 new unit tests cover the dynamic dictionary, every supported
element/attribute/xmlns/text record form (including round-trip),
explicit byte pinning for the collapse behavior, Chars width-variant
selection, unknown-record rejection, and truncated-payload rejection.

Records left for follow-up: Decimal, UniqueId, TimeSpan, Float/Double
text, DateTime text, Bytes8/16/32, QNameDictionary, the 0x0C-0x25
prefix-dict-attribute / 0x26-0x3F prefix-attribute / 0x44-0x77
prefix-element families. None of these are on the proven ASB path.

With F21 landed, the M5 framing + encoder layer (streams A+B+C+D and
the F24 codec) is complete. F25 (mxaccess-asb IASBIDataV2 client) and
F26 (Session over AsbTransport) remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:10:50 -04:00
Joseph Doherty 43c10a15ca [M5] mxaccess-asb-nettcp: F22 [MC-NBFS] static dictionary subset
Ports the curated subset of the `[MC-NBFS]` §2.2 static dictionary to
`mxaccess-asb-nettcp::nbfs`. Approximately 80 entries covering SOAP 1.2
envelope tokens, WS-Addressing 1.0 tokens, WS-RM, WS-Security,
WS-Trust/SecureConversation, XML Schema Instance primitives, plus the
common XML element / attribute names captured in
`analysis/proxy/mxasbclient-*` traces.

API:
* `STATIC_ENTRIES: &[StaticEntry]` — sorted-by-id table; one-line
  extension when wire captures show new IDs.
* `lookup_static(id) -> Option<&'static str>` — binary-search lookup
  for the F21 NBFX decoder.
* `position_of_static(value) -> Option<u32>` — `OnceLock`-cached
  reverse lookup for the F21 NBFX encoder.

Lookups outside the curated subset return `None`. The NBFX decoder
will surface that as a typed `UnknownStaticDictionaryId` error so the
caller knows to either extend the table or fall through to the
inline-string path. The full 487-entry table is bounded but tedious;
the deliberate subset keeps source size down while remaining
extensible.

ASB-specific contract strings (`http://ASB.IDataV2`,
`http://asb.contracts/20111111`, the IASBIDataV2 operation actions,
etc.) are intentionally **not** in the static dictionary — they live
in the per-session dynamic dictionary that the F21 NBFX codec builds
up via `DictionaryString` records.

6 unit tests cover monotonic-id invariant, known-id lookup,
unknown-id rejection, round-trip lookup consistency, and the
empty-string slot at id=142.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:06:11 -04:00
Joseph Doherty 9dfd1937c2 [M5] mxaccess-asb-nettcp: F20 [MS-NMF] .NET Message Framing record codec
Implements the 13 record types from `[MS-NMF]` §2.2 (Version, Mode, Via,
KnownEncoding, ExtensibleEncoding, Unsized/SizedEnvelope, End, Fault,
UpgradeRequest/Response, PreambleAck, PreambleEnd) over a `net.tcp` channel.

Includes the `Multibyte Int31` length codec (LEB128-style 7-bit groups
over a 31-bit unsigned range, max 5 bytes; rejects negative input and
overflow), plus an `encode_preamble` helper that emits the canonical ASB
connect record sequence (`Version 1.0 → Duplex → Via $uri →
BinaryWithDictionary → PreambleEnd`).

Pure codec — no I/O. Encoders write into a `Vec<u8>`; decoders parse
from a `&[u8]` slice and return the consumed-byte count alongside the
record. Higher-level connect/request/response orchestration stays in the
M5 ASB client (`mxaccess-asb`, F25).

24 new unit tests cover round-trip for every record type, multibyte-int31
boundary cases (0, 1, 127, 128, 16383, 16384, 200, i32::MAX), preamble
emission against the canonical ASB sequence, byte-layout pinning for
Version/Mode/KnownEncoding, and rejection of unknown record/mode/encoding
bytes plus truncated sized-envelope frames.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:01:24 -04:00
Joseph Doherty 7611d9e215 [M5] mxaccess-codec: F24 ASB Variant + AsbStatus + RuntimeValue codec
Ports `Variant` (cs:1170-1241), `AsbStatus` (cs:1109-1167), `RuntimeValue`
(cs:741-791), `AsbVariantFactory.From*` (cs:1310-1429), and
`MxAsbDataClient.DecodeVariant` (cs:713-825) into `mxaccess-codec::asb_variant`.

Three layers per `docs/ASB-Variant-Wire-Format.md`:
1. `AsbVariant` — raw 2/4/4/payload header + bytes; round-trips byte-identical.
2. `DecodedVariant` — typed view with one variant per proven ASB scalar / array
   (`Bool`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `Duration` plus
   array forms). Type ids outside the proven matrix surface as
   `Unsupported { type_id, payload }` — same fallback as .NET's `_ => payload`.
3. `from_*` factories — mirror `AsbVariantFactory.FromX` exactly, setting
   `length` to `payload.len()` per `cs:1431-1438`.

`AsbStatus` and `RuntimeValue` round-trip the wire layout verbatim.
Status-element walking (marker bit 7 = implicit zero, etc., per
`docs/ASB-Variant-Wire-Format.md:180-205`) is deferred to a follow-up; the
codec exposes the raw status payload bytes for now, matching .NET's
`AsbStatus.Payload = byte[]` shape.

The lib.rs `AsbVariant` / `AsbStatus` / `RuntimeValue` stubs are replaced by
the real types via `pub use`. 25 new unit tests cover the proven matrix:
scalar + array round-trip, byte layout (2/4/4/payload), `Unsupported`
fallback for declared-but-unproven types, short-frame rejection,
malformed `string[]` partial-decode preservation matching .NET behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:47:11 -04:00
Joseph Doherty ed17c07c10 [M5] mxaccess-asb-nettcp: M5 plan + F19 deps + F23 auth crypto port
F18 plans M5 as 9 sub-followups (F18-F26 + F27 constant-time DH) per
design/dependencies.md:73-89. Wave-1 streams F20-F23+F24 are parallel-safe
after F19 (workspace deps). F25 (ASB client) is sequential after the
framing/encoder streams. F26 (Session over AsbTransport) is sequential
after F25.

F19 — workspace deps for the M5 crypto + framing surface: hmac, md-5,
sha1, sha2, aes, cbc, pbkdf2, flate2, rand, num-bigint, num-traits,
num-integer, quick-xml, tokio-util, zeroize. Pinned to the digest 0.10 /
cipher 0.4 generation matching mxaccess-rpc.

F23 — ports `AsbSystemAuthenticator.cs` (167 LoC) to
`mxaccess-asb-nettcp::auth`. Wire-byte parity points: .NET BigInteger
little-endian two's-complement byte order with optional 0x00 sign-byte
suffix; AES-128-CBC with PKCS7 padding; PBKDF2-SHA1 1000 iterations
over `Convert.ToBase64String(crypto_key)` with ASCII salt
"ArchestrAService"; deflate-then-AES (Baktun) vs raw-AES (Apollo)
selected by `:V2` lifetime suffix; HMAC-MD5/SHA1/SHA512 negotiated per
`AsbSolutionCryptoParameters.HashAlgorithm` (with `force_hmac=true`
fallback to HMAC-SHA1 for unrecognised algorithms).

13 unit tests cover the cryptographic primitives + DH peer agreement +
.NET byte-order round-trip + Apollo lifetime dispatch.

F27 — filed for the `num-bigint` → `crypto-bigint::BoxedUint` swap once
the latter exposes a stable heap-allocated `pow_mod`. Currently at
parity with the .NET reference (also not constant-time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:36:15 -04:00
41 changed files with 12869 additions and 54 deletions
+135 -1
View File
@@ -46,7 +46,141 @@ move to `## Resolved` with a date + commit hash.
**Resolves when:** F19-F26 are all closed and the four DoD bullets above pass.
**This-iteration execution slice.** Land F19 (workspace deps) sequentially first, then F23 (auth crypto port — smallest stream, fully self-contained, exercises the largest set of new deps in one place to validate the dep choice). F20/F21/F22/F24/F25/F26 stay open for follow-up iterations or parallel agent fan-out.
**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 (`1b1ee1e`); F26 step 1 (`8a0f92b`); F26 step 2 (`14bb529`); example rewrite (`c6570dc`); F25 step 8 (`b543eb1`); F25 step 9 (`0441a2e`); F25 step 10 (`9876b4e`); F26 step 3 (`<previous>`); **F25 live-bring-up reconciliation** (this commit):
- F25 live-bring-up reconciliation: live `asb-subscribe` + `asb-relay` (TCP middleman) capture-and-diff against AVEVA's MxDataProvider on Windows. Five concrete fixes landed:
1. **NBFX `PrefixElement_a..z` (0x5E-0x77) and `PrefixAttribute_a..z` (0x26-0x3F) decode + encode arms** — single-letter-prefix records that WCF emits in responses but our codec only recognised the dictionary-named cousins (`PrefixDictionaryElement_a..z` 0x44-0x5D, `PrefixDictionaryAttribute_a..z` 0x0C-0x25). The server's ConnectResponse hit `0x65 = PrefixElement_h` for a dynamically-named element (e.g. `<h:Foo>`) and our decoder bailed with `unknown NBFX record byte 0x65`. Both directions now round-trip; the encoder picks the short-form arm whenever `prefix_letter_offset(prefix).is_some()`.
2. **xmlns redeclaration on `<Data>` and `<InitializationVector>` inside `AuthenticationData` / `PublicKey`**`[XmlType(Namespace = "http://asb.contracts.data/20111111")]` on the AuthenticationData / PublicKey classes (`AsbContracts.cs:350-381`) means XmlSerializer emits an `xmlns="..."` redeclaration on each direct child. The default-ns scope ends at `</Data>`, so `<InitializationVector>` needs its own redeclaration to stay in the data namespace; without it the server fell back to messages-namespace and the deserialiser threw an `InternalServiceFault`. Connect handshake now completes end-to-end with the apollo:V2 ConnectionLifetime and a real ServicePublicKey.
3. **SOAP-fault detection on the response path**`ClientError::SoapFault { action, code, reason }` surfaces when the response Action header matches the canonical `dispatcher/fault` template; we previously let body decoders blindly run and hit `MissingField { field: "Status" }` which masked the fact that the wire was a fault. The reason text is extracted as the longest `NbfxText::Chars` in the body — robust against the `nbfs.rs` static-dictionary id mismatches noted below.
4. **Identified blocker**: `ConnectedRequest` signing currently HMACs the **NBFX wire bytes** of the unsigned envelope. .NET's `AsbSystemAuthenticator.Sign` (`AsbSystemAuthenticator.cs:79`) HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the **canonical XML serialisation** of the message contract via `XmlSerializer` with namespace `"urn:invensys.schemas"` (`AsbSerialization.cs:12-48`). Until the Rust port emits identical XML bytes, the HMAC mismatches and the server rejects every signed request (`AuthenticateMe`, `RegisterItems`, etc.) with a generic `dispatcher/fault` InternalServiceFault. Connect itself is unsigned (extends `ServiceMessage`, no `ConnectionValidator` header) which is why it works today. The fault's `a:RelatesTo` UniqueId in our captures matches the AuthenticateMe `MessageID`, confirming the failure point. **New followup F28** captures the XML-canonicaliser scope.
5. **`nbfs.rs` static dictionary ids drift** at id 114+ vs. the canonical `[MC-NBFS]` table (`Fault`/`Code`/`Reason`/`Text`/`Value` are 20 IDs higher on the wire than what we encode). Doesn't affect requests we send (we only encode IDs ≤44 = `ReplyTo`, all correct), but breaks `decode_envelope`'s element-by-name matching for fault bodies. Tracked as **F29**.
Workspace: 702 tests pass (no test count delta — wire-only fixes). Live status: Connect handshake working with real DH key + apollo encryption; AuthenticateMe and onwards blocked on F28. Companion diagnostic example `asb-relay.rs` (TCP middleman that hex-dumps both directions to stderr) lands as a permanent debugging aid.
- F26 step 3: `mxaccess::AsbSession` — high-level cheap-clone async API on top of `AsbTransport`. Parallel to the NMX-shaped `Session` rather than unified, because NMX's `Session` carries orchestration (`CallbackExporter`, callback router task, recovery broadcast, `INmxService2` mutex) that has no ASB analogue, and ASB's request/response loop over a single TCP stream maps naturally to a `Mutex<AsbClient>` that would be foreign to NMX. The struct is `Clone + Send + Sync` (compile-time `assert_clone_send_sync` test guards the contract) — clones share inner state through `Arc<AsbSessionInner { transport: Mutex<AsbTransport<TcpStream>>, connect_response }>`, so each `clone()` is `O(1)` and the lock serialises operation calls. API surface: `AsbSession::connect(endpoint, passphrase, crypto_parameters, via_uri, connection_id)` runs the full bring-up; `from_transport(transport, connect_response)` builds from an existing transport for tests; `connect_response()` exposes the negotiated lifetime / Apollo flag. Operation methods forward to AsbClient: `register_items`/`unregister_items`/`read`/`write`/`keep_alive`/`disconnect`/`create_subscription`/`add_monitored_items`/`publish`/`delete_monitored_items`/`delete_subscription`/`publish_write_complete`. ClientError → mxaccess::Error mapping via `ConnectionError::TransportFailure` (consistent with F26 step 2). 1 new test (compile-time Clone+Send+Sync assertion). **Stubbed for next F26 iteration**: `Stream<Item = MonitoredItemValue>` subscription handle that internally drives a publish-loop, recovery/reconnect policy, and full live-probe wire-byte reconciliation. Workspace: 702 tests pass.
**Earlier slices:**
- F25 step 10 (commit `9876b4e`):
- F25 step 10: PublishWriteComplete + DeleteMonitoredItems — closes out the F25 operation matrix. `build_publish_write_complete_request_body` emits the empty wrapper element per `AsbContracts.cs:204-205` (no body fields beyond ConnectionValidator). `decode_publish_write_complete_response` returns a count of `<ItemWriteComplete>` elements observed; per-element decode (Status array + WriteHandle) is deferred to a later iteration since `ItemWriteComplete` is regular WCF DataContract rather than the binary fast-path. `build_delete_monitored_items_request_body` mirrors AddMonitoredItems but omits the RequireId field per `cs:268-277`. `decode_delete_monitored_items_response` returns the per-item Status array. Two new client wrappers: `publish_write_complete()` and `delete_monitored_items(subscription_id, items)`. 6 new tests cover empty-body shape, ItemWriteComplete counting (0 / 2 elements), DeleteMonitoredItems body shape (carries SubscriptionId + MonitoredItem), DeleteMonitoredItems omits RequireId, and Status round-trip. **F25 operation matrix complete**: AsbClient now wraps every IASBIDataV2 operation: `connect`/`disconnect`/`send_end`/`send_preamble`/`keep_alive` (lifecycle), `register_items`/`unregister_items`/`read`/`write` (items), `create_subscription`/`add_monitored_items`/`publish`/`delete_monitored_items`/`delete_subscription` (subscriptions), `publish_write_complete` (write callback). Workspace: 701 tests pass (was 695, +6).
**Earlier slices:**
- F25 step 9 (commit `0441a2e`):
- F25 step 9: Write operation. New `MinimalWriteValue { value: AsbVariant }` carries just the `Value` payload; optional ArrayElementIndex/Comment/HasQT/Status/Timestamp WriteValue fields are deferred to a later iteration once a live capture confirms the WCF DataContract XML form. New `build_write_request_body(items, values, write_handle)` produces the full `WriteBasicRequest` body shape per `AsbContracts.cs:181-194`: Items array uses the IAsbCustomSerializableType binary fast-path (`<Items><ASBIData>{...}</ASBIData></Items>`), each Value's inner `Variant` field also uses the fast-path (`<WriteValue><Value><ASBIData>{...}</ASBIData></Value></WriteValue>`), and WriteHandle is an Int32. New `decode_write_response` returns the per-item Status array. New `client::write(items, values, write_handle)` wrapper. 4 new tests cover Write request body shape (carries Items array, parallel Values array with WriteValue elements, WriteHandle as Int32), parallel-array sizing (2 items + 2 values produces 2 WriteValue elements), Status round-trip, and missing-Status error. Workspace: 695 tests pass (was 691, +4). The IASBIDataV2 read+write+subscribe path is now functionally complete in-memory.
**Earlier slices:**
- F25 step 8 (commit `b543eb1`):
- F25 step 8: subscription operations — `CreateSubscription`, `AddMonitoredItems`, `Publish`, `DeleteSubscription`. New `MonitoredItemValue` codec in contracts.rs (`IAsbCustomSerializableType` binary fast-path: ItemIdentity + RuntimeValue + AsbVariant per `cs:1064-1068`). New `MinimalMonitoredItem` request struct exposing only the proven fields (Item, SampleInterval, Buffered) — optional Active/TimeDeadband/ValueDeadband/UserData deferred to a later iteration once a live capture confirms the WCF DataContract XML shape. Per-operation builders, response decoders, and client wrappers follow the established F25 pattern. New `BodyField::Int64Element` variant for the `<SubscriptionId>` / `<MaxQueueSize>` / `<SampleInterval>` primitive fields. The subscription path lifts the `examples/asb-subscribe.rs` "Read-loop" caveat — once wire-byte reconciliation lands, the example can do `create_subscription → add_monitored_items → publish-loop → delete_subscription`. 11 new tests cover MonitoredItemValue round-trip + array, CreateSubscription request body shape + response decode (Int64 + Chars text fallback + missing-field error), AddMonitoredItems request body shape + response decode, DeleteSubscription request body, Publish request + response (with full Status + Values round-trip via the in-memory body synthesis pattern).
**Earlier slices:**
- example rewrite (commit `c6570dc`):
- `examples/asb-subscribe.rs` rewrite: replaces the M5 placeholder with an actual end-to-end demo that exercises the F25 + F26 stack: `AsbTransport::connect` (TCP + preamble + DH handshake) → `register_items``read``disconnect``send_end`. Reads endpoint config from `MX_ASB_HOST`, `MX_ASB_PASSPHRASE`, `MX_ASB_VIA`, `MX_TEST_TAG` env vars (analogous to the NMX `connect-write-read` example's pattern). Defaults port 5074 when host omits one; defaults via URI to `net.tcp://{host}/ASBService` when `MX_ASB_VIA` is unset. Without `MX_LIVE` set, prints the `Setup-LiveProbeEnv.ps1` hint and exits cleanly. Connection-id is a fresh 16-byte random buffer (matches .NET's `Guid.NewGuid()` at `MxAsbDataClient.cs:36`). The example is a Read-loop until F25 subscription ops land — at that point the example will gain a Publish-loop and live up to its name.
**Earlier slices:**
- F26 step 2 (commit `14bb529`):
- F26 step 2: `AsbTransport::connect(endpoint, passphrase, crypto_parameters, via_uri, connection_id)``tokio::net::TcpStream`-specialised async constructor that owns the full transport-bring-up sequence: TCP connect → NMF preamble exchange → DH Connect handshake → AuthenticateMe one-way (signed). Returns `(AsbTransport<TcpStream>, ConnectResponse)` so callers can inspect the negotiated lifetime / Apollo-vs-Baktun flag from the response. New `ConnectionError::TransportFailure { detail }` variant carries the underlying error message (NMF / NBFX / auth / I/O) without exploding the public taxonomy. Errors are mapped at the AsbClient/Auth boundary via `map_client_error` / `map_auth_error` helpers. 1 new test confirms a connect to an unreachable endpoint (127.0.0.1:1, TCPMUX-reserved) surfaces an `Err` cleanly without panicking. **Stubbed for F26 step 3:** `Session::connect_asb` constructor (the SessionInner refactor needed to host both NMX + ASB transports under one struct is heavier than this iteration's scope), plus the operation-routing layer that maps ASB result types (`ItemStatus`, `RuntimeValue`) back to `mxaccess` types (`MxStatus`, `DataChange`, `MxValue`).
**Earlier slices:**
- F26 step 1 (commit `8a0f92b`):
- F26 step 1: `mxaccess::AsbTransport` — bridges F25's `AsbClient` into the M0 `Transport` trait. Generic over `T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static` (the same bounds AsbClient takes). `Transport::capabilities()` returns the ASB-specific flags per `design/60-roadmap.md` M5: `buffered_subscribe = false`, `activate_suspend = false`, `operation_complete_frame = false`. `Transport::kind()` returns `TransportKind::Asb`. `AsbTransport::new(client)` / `into_client()` / `client_mut()` for transport↔client conversion. New deps: `mxaccess` now path-deps `mxaccess-asb` + `mxaccess-asb-nettcp`. Compile-time `Send + Sync + 'static` assertion guards the trait-bound contract. 2 new tests: kind == Asb; capabilities all false. **Stubbed for F26 step 2:** `Session::connect_asb` constructor that owns the full TCP-open + preamble + DH handshake orchestration, plus operation routing that maps ASB types (`ItemStatus`, `RuntimeValue`) back to `mxaccess` types (`MxStatus`, `DataChange`, `MxValue`). Stubbed for F26 step 3: subscription routing — `Session::subscribe` on ASB maps to a `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback pipeline; F25 subscription operations themselves are not yet implemented.
**Earlier slices:**
- F25 step 7 (commit `1b1ee1e`):
- F25 step 7: Disconnect operation (closes the connection lifecycle: Connect → ops → Disconnect → End → close). New `build_disconnect_request_body(data, iv)` mirrors `AsbContracts.cs:109-114` (`<DisconnectRequest><ConsumerAuthenticationData><Data/><InitializationVector/></ConsumerAuthenticationData></DisconnectRequest>`) — same payload shape as AuthenticateMe but under a different wrapper element. New `client::disconnect()` builds a fresh encrypted authentication-data blob via F23's `create_authentication_data` (encrypts `local_pub || remote_pub` under the derived AES key with a fresh IV), wraps it, and sends one-way + signed (regular HMAC, no force). 2 new tests: `disconnect_request_carries_data_and_iv_under_correct_wrapper` (checks wrapper element name + Data/IV byte ordering), and end-to-end `disconnect_writes_signed_one_way_envelope` via `tokio::io::duplex` peer that verifies the encoded SizedEnvelope contains the disconnectIn action string. With Disconnect landed, `AsbClient` now covers the full session lifecycle: `send_preamble().await? → connect().await? → register_items()/read()/keep_alive()/unregister_items() → disconnect().await? → send_end().await?`.
**Earlier slices:**
- F25 step 6 (commit `321b796`):
- F25 step 6: Connect + AuthenticateMe handshake — the critical-path piece that turns a fresh TCP stream into an authenticated session. New `build_connect_request_body` (carries connection-id GUID + consumer public key bytes; sent **unsigned** because no shared secret exists yet), `build_authenticate_me_request_body` (carries encrypted Data + IV; sent **one-way + signed with `forceHmac=true`** per `MxAsbDataClient.cs:106-111`), `decode_connect_response` (extracts ServicePublicKey, optional ServiceAuthenticationData, optional ConnectionLifetime — handles the `:V2` Apollo lifetime suffix that toggles F23's encryption mode), `AuthenticationDataBytes` struct, and `client::connect` orchestration that runs the full handshake: ConnectRequest → ConnectResponse → `accept_connect_response` (derives shared secret) → `create_authentication_data` (encrypted local_pub || remote_pub) → AuthenticateMeRequest one-way. 6 new tests cover ConnectRequest body shape (carries hyphenated GUID + public-key bytes), AuthenticateMe body shape (Data + IV bytes), ConnectResponse round-trip with all optional fields, ConnectResponse without optional fields, MissingField error when ServicePublicKey absent, and an end-to-end client::connect handshake test via `tokio::io::duplex` peer that synthesises a ConnectResponse with bob's public key (so DH shared-secret derivation works) and drains the AuthenticateMe one-way SizedEnvelope. **Wire-byte caveat**: WCF XML serialization of `<PublicKey><Data>byte[]</Data>` may include `xsi:type` attributes or distinct namespaces that this builder doesn't yet emit; live-probe iteration will reconcile.
**Earlier slices:**
- F25 step 5 (commit `9b8133f`):
- F25 step 5: extends `AsbClient` with one-way operation support + `KeepAlive` + `Read` wrappers. New `send_envelope_one_way` / `send_signed_envelope_one_way` mirror WCF's `[OperationContract(IsOneWay = true)]` semantics — write the SizedEnvelope and return immediately. New `client::keep_alive` ports `MxAsbDataClient`'s channel inactivity-keepalive (`AsbContracts.cs:117` — empty wrapper element + ConnectionValidator header). New `client::read` + `decode_read_response` (in operations) decode `Status` (`Vec<ItemStatus>`) + `Values` (`Vec<RuntimeValue>`) from the dual-`<ASBIData>`-payload `ReadResponse` body shape. RuntimeValue array decode mirrors `AsbContracts.cs:771-780` (4-byte int32 count + per-element `WriteToStream`). 5 new tests: keep_alive body shape (empty wrapper), ReadResponse round-trip with Status + Values, ReadResponse-with-no-Values graceful handling, plus two end-to-end client tests via `tokio::io::duplex` peer (keep_alive one-way send drains the SizedEnvelope but produces no response, read round-trips Status + Values from a synthetic ReadResponse).
**Earlier slices:**
- F25 step 4 (commit `1e59249`):
- F25 step 4: `mxaccess-asb::client::AsbClient` — async network loop generic over `AsyncRead + AsyncWrite + Unpin + Send`. Wraps the F19-F25.3 stack into a single struct with: `send_preamble` (writes the canonical NMF preamble + waits for PreambleAck; errors on Fault), `send_envelope` (frames in `SizedEnvelope`, writes, reads response, decodes back to `DecodedEnvelope`), `send_signed_envelope` (calls F23 authenticator's `sign` on the unsigned body bytes, attaches a `ConnectionValidator` header, sends), `register_items` / `unregister_items` thin wrappers, `send_end` (writes record `0x07` + shutdowns the stream), and `authenticator_mut` accessor for the future Connect/AuthenticateMe flow. Generic transport means tests use `tokio::io::duplex` for in-memory verification — no live ASB endpoint needed. 6 new tests cover preamble round-trip, fault propagation through preamble, full RegisterItems request → response round-trip via in-memory peer, send-before-preamble guard, send-end record byte (`0x07`), and `PreambleMode` re-export shape. **Note**: the signing path currently hashes the NBFX-encoded body; .NET hashes the XML-text `request.ToXml()`. Functionally present but byte-non-identical to .NET's MAC for the same payload. Live-probe iteration needs to reconcile this — flagged as `TODO` in the doc comment.
**Earlier slices:**
- F25 step 3 (commit `c4bf0a0`):
- F25 step 3: response decoder foundation. New `mxaccess-asb::contracts::ItemStatus` ports `AsbContracts.cs:639-722` — Item (ItemIdentity) + Status (AsbStatus, F24) + ErrorCode u16 + ErrorCodeSpecified bool, in the .NET-WriteToStream order (Item / Status / ErrorCode / ErrorCodeSpecified — NOT the DataMember declaration order). `encode_item_status_array` / `decode_item_status_array` follow the same int32-count + per-element pattern. New `mxaccess-asb::operations::collect_asbidata_payloads(tokens, field_name)` walks an NBFX token stream and pulls out the `<{field_name}><ASBIData>{Bytes}</ASBIData></{field_name}>` payload bytes — handles multiple payloads (e.g. ReadResponse has both Status and Values). New `decode_register_items_response` / `decode_unregister_items_response` parse SOAP bodies into typed responses. New `build_read_request_body` adds the simplest unary IASBIDataV2 request shape. Plus a typed `OperationError` for response-decode failures (missing fields, codec errors). 9 new tests cover ItemStatus round-trip + array round-trip, RegisterItems response with status array, RegisterItems response detecting ItemCapabilities presence, UnregisterItems response, multi-payload extraction (`ReadResponse`-style with Status + Values), Read request body shape (no RegisterItems-only fields), and graceful MissingField error when Status is absent.
**Earlier slices:**
- F25 step 2 (commit `a2b8989`):
- F25 step 2: per-operation request-body builders + `IAsbCustomSerializableType` binary fast-path. F21 NBFX gains `Bytes8/16/32` text records (used by `XmlDictionaryWriter.WriteBase64` for the `<ASBIData>` content). New `mxaccess-asb::contracts::ItemIdentity` ports the binary `WriteToStream` shape from `AsbContracts.cs:594-611`: u16 kind + u16 reference_type + `AsbBinary.WriteUnicodeString` Name + ContextName + u64 Id + u8 IdSpecified. Plus `encode_item_identity_array` / `decode_item_identity_array` mirroring `WriteArrayToStream` (4-byte int32 count + items). New `mxaccess-asb::operations` builds the SOAP body NBFX token streams: `build_register_items_request_body(items, require_id, register_only)` and `build_unregister_items_request_body(items)`. The `<ASBIData>` element is wrapped with raw NBFX `Bytes` records (the binary form of WCF's `WriteBase64`). 14 new tests cover ItemIdentity round-trip (default, with id, unicode), ItemIdentity array round-trip, AsbBinary unicode-string null/empty/value semantics, byte-layout pinning (21-byte minimum for default ItemIdentity, le-int32 array count), and the full RegisterItems → SoapEnvelope → encode → decode → recover-ItemIdentity-array round-trip through the entire stack.
**Earlier slices:**
- F25 step 1 (commit `25dbd8d`):
- F25 step 1: `mxaccess-asb::envelope` — SOAP-1.2-over-NBFX envelope assembly + parsing for the `IASBIDataV2` contract. Provides `actions::*` constants for all 14 operations (verbatim from `AsbContracts.cs:14-58`), a `ConnectionValidator` header struct that converts F23's `SignedValidator` (`mac` + `iv` get base64-encoded for the wire), `SoapEnvelope` builder, `encode_envelope` (NBFX-token assembly: `s:Envelope``s:Header``a:Action s:mustUnderstand="1"` → optional `h:ConnectionValidator``s:Body``body_tokens`), and `decode_envelope` (tolerant of header ordering — looks for Action and ConnectionValidator anywhere inside `<s:Header>`). Includes a `format_uuid`/`parse_uuid` pair that mirrors .NET's `Guid.ToString("D")` mixed-endian byte order so connection-id round-trip matches the wire. 9 unit tests cover round-trip with/without validator, validator-from-SignedValidator base64 encoding, .NET-mixed-endian GUID format, action-string presence in encoded bytes, missing-Action tolerance, and full validator round-trip through encode→decode. **Stubbed for next F25 iteration:** per-operation request/response struct codecs (`ConnectRequest`, `RegisterItemsRequest`, etc. with the `IAsbCustomSerializableType` binary fast-path that .NET uses for `Variant`/`AsbStatus`/`RuntimeValue`), and `AsbClient` (TCP + NMF preamble + sized-envelope read/write loop + auth handshake).
**Earlier slices:**
- F21 (commit `5f98558`):
- F21: `mxaccess-asb-nettcp::nbfx` ports the `[MC-NBFX]` `.NET Binary XML Format` token codec — the proven subset for ASB. Token model: `Element { prefix, name }` / `EndElement` / `Attribute { prefix, name, value }` / `DefaultNamespace` / `NamespaceDeclaration` / `Text`. Name forms: inline UTF-8, `[MC-NBFS]` static-dictionary id, per-session `DynamicDictionary` id. Text forms: Empty, Zero, One, Bool, Int8/16/32/64, Chars (Chars8/16/32 width variants chosen automatically), and `DictionaryText` static/dynamic refs. The `*WithEndElement` text variants are collapsed automatically: `Text → EndElement` pairs encode as the `+1` record byte (e.g. `EmptyTextWithEndElement = 0xA9`); decoder splits them back out so consumers see the same token stream. 15 unit tests cover the dynamic-dictionary semantics, all element/attribute/xmlns/dict-text record forms, the collapse behavior with explicit byte pinning (`0x87` TrueTextWithEndElement, `0xA9` EmptyTextWithEndElement), Chars width-variant selection (Chars8 / Chars16 / Chars32 by length), unknown-record rejection, and truncated payloads. Records left for follow-up: Decimal, UniqueId, TimeSpan, Float/Double text, DateTime text, Bytes8/16/32, QNameDictionary, the `0x0C-0x25`/`0x26-0x3F` prefix-attribute and `0x44-0x77` prefix-element families.
**Earlier slices:**
- F22 (commit `43c10a1`):
- F22: `mxaccess-asb-nettcp::nbfs` ports `[MC-NBFS]` §2.2 static dictionary table — the curated subset (~80 entries) covering SOAP 1.2 envelope, WS-Addressing 1.0, xsi/xsd primitives, common XML element/attribute names. `lookup_static(id)` and `position_of_static(value)` plus a `OnceLock`-cached reverse map. Lookups against unmapped IDs return `None` so the F21 NBFX decoder surfaces a clear error rather than silently corrupting. Extending the table is a one-line append in numerical order; existing tests assert monotonic IDs to catch transposition.
**Earlier slices:**
- F20 (commit `9dfd193`):
- F20: `mxaccess-asb-nettcp::nmf` ports the `[MS-NMF]` `.NET Message Framing` record codec — Version, Mode, Via, KnownEncoding, ExtensibleEncoding, Unsized/SizedEnvelope, End, Fault, UpgradeRequest/Response, PreambleAck, PreambleEnd. `Multibyte Int31` (LEB128 over 31-bit unsigned) implementation with overflow + negative-length rejection. `encode_preamble` helper emits the canonical ASB connect sequence (`Version 1.0 → Duplex → Via $uri → BinaryWithDictionary → PreambleEnd`). 24 unit tests cover record round-trip for every record type, multi-byte length boundary cases (0/1/127/128/16383/16384/200/i32::MAX), preamble emission, byte-layout pinning for Version/Mode/KnownEncoding, and rejection of unknown record/mode/encoding bytes plus truncated sized-envelope frames.
**Earlier slices:**
- F24 (commit `7611d9e`):
- F24: `mxaccess-codec::asb_variant` ports `Variant` + `AsbStatus` + `RuntimeValue` from `AsbContracts.cs:1109-1241,741-791` plus `MxAsbDataClient::DecodeVariant` + `AsbVariantFactory` from `cs:713-825,1310-1429`. Wire layout per `docs/ASB-Variant-Wire-Format.md`. `AsbVariant` is the raw 10-byte-header + payload form; `DecodedVariant` is the typed view; `from_*` factories mirror .NET's `From*`. 25 unit tests cover all proven scalar/array types' round-trip, byte layout (2/4/4/payload), `Unsupported` fallback for type ids outside the proven matrix, `AsbStatus` round-trip, `RuntimeValue` round-trip, malformed `string[]` partial-decode preservation, and short-frame rejection.
**Earlier slices:**
- F19 + F23 (commit `ed17c07`):
- F19: workspace deps added (`hmac`, `md-5`, `sha1`, `sha2`, `aes`, `cbc`, `pbkdf2`, `flate2`, `rand`, `num-bigint`, `num-traits`, `num-integer`, `quick-xml`, `tokio-util`, `zeroize`) + crate `Cargo.toml` propagation.
- F23: `mxaccess-asb-nettcp::auth` ports `AsbSystemAuthenticator` (167 LoC .NET → ~480 LoC Rust + tests). 13 tests cover decimal-prime parsing, .NET `BigInteger` byte-order round-trip (sign-byte append/strip + zero), base64 against RFC 4648 §10 vectors, public-key range, private-key sizing, peer-to-peer DH shared-secret agreement, signed-validator message-number monotonicity, AES-CBC PKCS7 padding, unknown hash algorithm fallback (no MAC unless `force_hmac=true`), Apollo `:V2` lifetime-suffix dispatch, PBKDF2-SHA1 self-consistency snapshot.
F25 (`mxaccess-asb` IASBIDataV2 client) and F26 (`mxaccess::Session` over `AsbTransport`) remain open. With F19-F24 landed, the M5 framing/encoder layer (streams A+B+C+D and the codec stream) is complete; F25 composes them into the `IASBIDataV2` wire client. F22's static dictionary subset is intentionally curated; expand entries as wire captures show new IDs. F27 (constant-time DH) is filed as a separate follow-up below.
### F30 — Resolve dict-id element/attribute names on the read side
**Severity:** P1 — blocks decoding any non-trivial WCF response.
**Source:** Live Register response decode (`MX_ASB_TRACE_REPLY` dump in `client.rs:172-190`).
**Why deferred:** When the server returns a response with the `RegisterItemsResponse` wrapper + `Result` fields, every element name (and most attribute names) is dict-encoded — `<b:Static(43)>false</b:Static(43)>` is `successField=false` on the wire. Our `decode_tokens` produces `NbfxName::Static(id)` tokens without resolving them; downstream consumers (`collect_asbidata_payloads`, `find_element_named`, `decode_register_items_response`) only match against `NbfxName::Inline(local)` and miss every dict-named element. The fault detection works because the SOAP fault Action header contains `/fault` (a literal string), but real success-response decoding is blind.
**Resolves when:** `decode_tokens` (or a post-pass over the token stream) substitutes `NbfxName::Static(id)` with `NbfxName::Inline(name)` whenever the dict id resolves to a known string. The dynamic dict (`read_dictionary`) accumulates session strings via `intern`; the read-path needs the parallel session counter to map wire ids to slots — wire ids are odd and session-cumulative across messages, mirroring the F28 fix on the write side. **Resolves**: F25 live data path (Read/Write/Subscribe responses are all dict-encoded too).
### F30 — Resolve dict-id element/attribute names on the read side (RESOLVED, commit `eb6c689`)
### F31 — InvalidConnectionId on first Register after AuthenticateMe — RESOLVED via retry
**Resolved:** `<this commit>`. Not a HMAC bug after all — `AsbErrorCode.InvalidConnectionId` (= 1) is a **transient race** condition that .NET's `MxAsbDataClient.RegisterMany` (`cs:191-204`) explicitly handles with a retry loop (`for (int attempt = 1; attempt < 5 && response.Result.ErrorCode == InvalidConnectionId; attempt++)` with `100*attempt` ms backoff). `AuthenticateMe` is one-way (`AsbContracts.cs:18`); the server commits auth state asynchronously after the request lands, and a Register that arrives too quickly sees the connection in pre-authenticated state. `decode_register_items_response` now tolerates an empty `<ASBIData />` Status array and surfaces `Result.resultCodeField` + `successField`; `AsbClient::register_items` retries up to 5 times on `RESULT_CODE_INVALID_CONNECTION_ID`, mirroring .NET. **Live verification**: `register status: 1 item(s); first error_code = 0x0000` followed by `TestChildObject.TestInt = AsbVariant { type_id: 4, length: 4, payload: [99, 0, 0, 0] }` — the real tag value `99` over the live wire, end-to-end.
### F28 — Canonical XML serialiser for `ConnectedRequest` signing (matches `XmlSerializer.Serialize` byte-for-byte)
**Severity:** P0 — blocks every signed ASB operation (AuthenticateMe, RegisterItems, all data-plane RPCs).
**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`. 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 (commit `dbb580b`).** `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. Plus `authenticate-me-empty-mac-iv.xml` (896 bytes) for the actual signing input shape (validator's MAC + IV are empty during `request.ToXml()`; .NET's `AsbSystemAuthenticator.Sign:79` mutates them only AFTER HMAC computation). 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 `<Xxx>` is emitted, (5) CRLF line endings + 2-space indent + UTF-8-bytes-of-utf-16-declaration, (6) empty `byte[]` → self-closing `<Tag xmlns="..." />` (NOT `<Tag></Tag>`).
**Emitter landed (commit `f14580e`).** `mxaccess-asb::xml_canonical` exposes `emit_authenticate_me_xml`, `emit_disconnect_xml`, `emit_keep_alive_xml`, `emit_register_items_request_xml`, `emit_unregister_items_request_xml`. Seven fixture-comparison tests pass (byte-equal vs. .NET output for both filled-MAC + empty-MAC variants of AuthenticateMe, plus the four other shapes). Plumbing: `AsbAuthenticator::peek_next_message_number` exposes the pre-allocated message number; `AsbClient::send_signed_envelope[_one_way]` gain `xml_for_signing: Option<&[u8]>`. `connect`, `disconnect`, `keep_alive`, `register_items`, `unregister_items` now build a pre-signing `ConnectionValidator` (empty MAC + IV) → emit canonical XML → pass to HMAC. Other ops (Read, Write, Subscription) still use the legacy NBFX-bytes path.
**Registry-driven DH params (commit `f14580e`).** `tools/Get-AsbPassphrase.ps1` exports `MX_ASB_DH_PRIME`, `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM`, `MX_ASB_DH_KEY_SIZE`. The `asb-subscribe` example honours those env vars to override `CryptoParameters::defaults()` (which is the .NET reference's 1024-bit fallback). Each AVEVA install picks its own DH group at provisioning time — typically a 768-bit prime, NOT the default 1024-bit. With the wrong prime, `Connect` succeeds at the byte level but the shared-secret derivation diverges, breaking AuthenticateMe's encrypted ConsumerData verification. Empty registry `hashAlgorithm` maps to `HashAlgorithm::Unrecognised` to match `AsbSystemAuthenticator.CreateHmac:84-93` semantics where empty + `forceHmac=true` falls through to HMAC-SHA1.
**Remaining live blocker (commit `fd38189`).** With canonical XML byte-equal to .NET's AND DH params from the registry, AuthenticateMe still produces `dispatcher/fault` InternalServiceFault. `MX_ASB_TRACE_DERIVE`-gated diagnostic traces in both the Rust authenticator and the .NET reference confirm: crypto_key length matches (176 bytes = 96-byte shared secret + 80-byte passphrase); passphrase bytes [96..176] of the crypto_key are identical between Rust and .NET (same registry source, same UTF-8 encoding). The shared-secret prefix [0..96] differs per session (random DH), but should round-trip correctly with the server.
**Crypto stack ruled out** (commit `<this commit>`). Deterministic-HMAC fixture test (`auth.rs::tests::deterministic_hmac_matches_dotnet_fixture`) takes pinned inputs (passphrase, prime, generator, private-key bytes, remote-pub bytes, message number, connection ID, AES IV, consumer-data + IV) and asserts byte-equality of each step:
1. `shared = remote_pub^private_key mod prime` — ✅ matches .NET
2. `crypto_key = shared || passphrase_utf8` — ✅ matches .NET
3. `hmac = HMAC-SHA1(crypto_key, xml_utf8)` — ✅ matches .NET (HMACSHA1)
4. `aes_key = PBKDF2-SHA1(base64(crypto_key), "ArchestrAService", 1000, 16)` — ✅ matches .NET (Rfc2898DeriveBytes.Pbkdf2)
5. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7)` — ✅ matches .NET (System.Security.Cryptography.Aes)
The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/MxAsbClient.Probe/Program.cs:166-296`), saved at `crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv`. With all 5 crypto steps proven byte-equal to .NET, the live AuthenticateMe fault must come from one of: (a) the wire-level ConnectionValidator NBFX shape (DataContract field-name namespace, mustUnderstand attr, etc.), (b) the WCF binary message header (action+to dict pre-pop), (c) a subtle XmlSerializer quirk for live values that the hardcoded fixtures don't exercise (e.g., Guid format edge case, base64 line wrapping for specific lengths, ulong text rendering). Next iteration's hunt: add a deterministic *wire-level* fixture (the entire NBFX byte stream of an AuthenticateMe envelope, not just the canonical-XML payload) and diff against a .NET probe capture for the same inputs.
### F29 — Align `mxaccess-asb-nettcp::nbfs` static dictionary ids with canonical `[MC-NBFS]` table
**Severity:** P2 — diagnostic-only today; blocks future fault-body decoding.
**Source:** F25 live-bring-up; observed wire ids (Fault=134, Code=142, Reason=144, Text=146, Value=154, Subcode=156) vs `nbfs.rs` (Fault=114, Code=122, Reason=124, Text=126, Value=134, Subcode=136). Off by 20 starting at the SOAP-fault subset.
**Why deferred:** Doesn't affect request encoding — every dict id we emit is ≤44 (`ReplyTo`) and those IDs are correct. The SOAP-fault element-by-name decode in `detect_soap_fault` was sidestepped by walking text records directly rather than relying on dict-resolved element names, so the user-facing fault reason still surfaces correctly. The dictionary mismatch is a latent issue that will bite when (a) we want richer fault decoding (parsing `<Code><Value>s:Receiver</Value></Code>` to surface the SOAP fault role) or (b) we encode anything in the upper id range (none of our current encoders do).
**Resolves when:** The 10 missing `[MC-NBFS]` §2.2 entries between `s` (id 112) and `Fault` (id 134) are inserted, and existing 114+ entries are renumbered by +20. The canonical reference is the `[MC-NBFS]` PDF (Microsoft Open Specifications) or the `XD.cs` / `ServiceModelStringsVersion1` table inside `System.ServiceModel`. Add a regression test that hands a captured fault envelope to `decode_envelope` and asserts both Code and Reason text resolve via dict lookup.
### F27 — Constant-time DH `mod_exp` (swap `num-bigint` → `crypto-bigint::BoxedUint`)
**Severity:** P2 (security regression vs the long-term Rust target — but at parity with the .NET reference today, so not a release-blocker)
**Source:** F23 (`crates/mxaccess-asb-nettcp/src/auth.rs:179,303`); originally flagged in `design/30-crate-topology.md:269-274` and the project's `review.md` MAJOR finding.
**Why deferred:** `crypto-bigint 0.5`'s `BoxedUint` does not yet expose `pow_mod` over heap-allocated values. The fixed-size `Uint<L>` types do, but require the prime to be parsed into a fixed bit-width and there's no decimal-string parser in `crypto-bigint`. F23 ships with `num-bigint` to keep parity with the .NET reference (which is also not constant-time); the constant-time upgrade is a separate, isolated swap.
**Resolves when:** Either (a) `crypto-bigint` lands a stable `BoxedUint::pow_mod` and a decimal-string parser, or (b) we add a small fixed-width DH backend that parses the registry prime into `U2048` once at session construction. At that point `auth::AsbAuthenticator::new`, `crypto_key`, and `generate_private_key` swap `num_bigint::BigUint::modpow` for the constant-time variant; tests stay unchanged because the wire-byte representation is identical.
### F2 — NTLM verify_signature path + constant-time MAC compare (server-to-client direction)
**Severity:** P2
+218 -2
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -13,6 +30,12 @@ dependencies = [
"syn",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -31,6 +54,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -43,12 +75,31 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.1"
@@ -57,7 +108,25 @@ checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
dependencies = [
"block-buffer 0.12.0",
"crypto-common 0.2.1",
"inout",
"inout 0.2.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
@@ -90,6 +159,16 @@ dependencies = [
"subtle",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -153,6 +232,12 @@ dependencies = [
"wasi",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
@@ -171,6 +256,16 @@ dependencies = [
"typenum",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.2"
@@ -217,6 +312,16 @@ dependencies = [
"digest",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -234,6 +339,8 @@ version = "0.0.0"
dependencies = [
"async-trait",
"futures-util",
"mxaccess-asb",
"mxaccess-asb-nettcp",
"mxaccess-callback",
"mxaccess-codec",
"mxaccess-galaxy",
@@ -252,11 +359,34 @@ version = "0.0.0"
dependencies = [
"mxaccess-asb-nettcp",
"mxaccess-codec",
"rand",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "mxaccess-asb-nettcp"
version = "0.0.0"
dependencies = [
"aes",
"bytes",
"cbc",
"flate2",
"hex",
"hmac",
"md-5",
"num-bigint",
"num-integer",
"num-traits",
"pbkdf2",
"rand",
"sha1",
"sha2",
"thiserror",
"tracing",
"zeroize",
]
[[package]]
name = "mxaccess-callback"
@@ -321,12 +451,50 @@ dependencies = [
"tokio",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -396,7 +564,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92"
dependencies = [
"cipher",
"cipher 0.5.1",
]
[[package]]
@@ -405,6 +573,34 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "slab"
version = "0.4.12"
@@ -653,3 +849,23 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+27
View File
@@ -31,6 +31,33 @@ futures-util = "0.3"
bytes = "1"
byteorder = "1"
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "sync", "time", "macros"] }
# M5 ASB transport (F19). Crypto crates target the digest 0.10 / cipher 0.4
# generation (the line that hmac 0.12, md-5 0.10, sha1 0.10, sha2 0.10,
# aes 0.8, cbc 0.1, pbkdf2 0.12 all share). mxaccess-rpc is already on this
# generation (crates/mxaccess-rpc/Cargo.toml:13-18); M5 sticks with it for
# resolved-graph coherence. The design doc at design/30-crate-topology.md:251-289
# prescribed the 0.11/0.5 generation but the rpc crate landed earlier on the
# 0.10/0.4 line — when those two diverge, the implementation is canonical.
hmac = "0.12"
md-5 = "0.10"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
cbc = { version = "0.1", features = ["std"] }
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
flate2 = "1"
rand = "0.8"
# DH bigint. NOTE: num-bigint::modpow is not constant-time. The DH private
# exponent is long-lived (AsbSystemAuthenticator.cs:153-166); .NET BigInteger
# also isn't constant-time, so we are at parity with the reference. Tracked
# as F27 to swap to crypto-bigint::BoxedUint once that crate exposes a stable
# pow_mod over heap-allocated values — design/30-crate-topology.md:269-274.
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
quick-xml = "0.36"
tokio-util = { version = "0.7", features = ["codec"] }
zeroize = { version = "1", features = ["zeroize_derive"] }
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "warn"
@@ -9,6 +9,25 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
thiserror = { workspace = true }
tracing = { workspace = true }
bytes = { workspace = true }
hmac = { workspace = true }
md-5 = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
aes = { workspace = true }
cbc = { workspace = true }
pbkdf2 = { workspace = true }
flate2 = { workspace = true }
rand = { workspace = true }
num-bigint = { workspace = true }
num-traits = { workspace = true }
num-integer = { workspace = true }
zeroize = { workspace = true }
[dev-dependencies]
hex = "0.4"
[lints]
workspace = true
+918
View File
@@ -0,0 +1,918 @@
//! ASB application-auth crypto.
//!
//! Port of `src/MxAsbClient/AsbSystemAuthenticator.cs` (167 LoC) — the DH
//! handshake, HMAC signing, and AES-128/PBKDF2-SHA1 key derivation that
//! `IASBIDataV2::Connect` + `AuthenticateMe` use to bring up an authenticated
//! ASB session.
//!
//! Notable parity points:
//!
//! * **DH `mod_exp` constant-time gap.** The .NET reference uses
//! `BigInteger.ModPow`, which is **not** constant-time. The Rust port
//! currently uses `num-bigint`, which is *also* not constant-time — so
//! this is parity, not a regression. The long-term target is
//! `crypto-bigint::BoxedUint` once that crate exposes a stable `pow_mod`
//! over heap-allocated values; see `design/30-crate-topology.md:269-274`
//! and follow-up F27 in `design/followups.md`.
//!
//! * **.NET `BigInteger` byte order.** Both
//! `BigInteger.ToByteArray` and `new BigInteger(byte[])` are
//! little-endian, two's-complement. For positive values whose top bit is
//! set, `ToByteArray` appends a trailing `0x00` sign byte. Wire-byte
//! parity for `LocalPublicKey` and the encrypted authentication-data
//! payloads requires reproducing that exact convention — see
//! [`bigint_to_dotnet_bytes`].
//!
//! * **AES key derivation.** PBKDF2-HMAC-SHA1 over
//! `Convert.ToBase64String(CryptoKey)` with the ASCII salt
//! `"ArchestrAService"`, 1000 iterations, 16-byte output (`cs:134-142`).
//! The base64 step is part of the spec, not a quirk — derived keys do
//! *not* match if the raw `CryptoKey` bytes are fed in directly.
//!
//! * **Lifetime-suffix dispatch.** `ConnectResponse.ConnectionLifetime`
//! carrying `:V2` selects the `EncryptApollo` path (raw AES-CBC).
//! Otherwise `EncryptBaktun` (deflate-then-AES-CBC). Mirrored verbatim
//! from `cs:48` / `cs:97-117`.
use std::io::Write as _;
use aes::Aes128;
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use cbc::Encryptor as CbcEncryptor;
use flate2::Compression;
use flate2::write::DeflateEncoder;
use hmac::digest::KeyInit;
use hmac::{Hmac, Mac};
use md5::Md5;
use num_bigint::BigUint;
use num_integer::Integer;
use num_traits::{One, Zero};
use pbkdf2::pbkdf2_hmac;
use rand::RngCore;
use sha1::Sha1;
use sha2::Sha512;
use zeroize::{Zeroize, Zeroizing};
/// PBKDF2 salt — ASCII bytes of `"ArchestrAService"`. Mirrors the .NET
/// `PasswordSalt` constant at `AsbSystemAuthenticator.cs:10`.
const PASSWORD_SALT: &[u8] = b"ArchestrAService";
/// PBKDF2 iteration count from `cs:139`.
const PBKDF2_ITERATIONS: u32 = 1000;
/// Derived AES key length in bytes, matching `cs:141` (`outputLength: 16`).
const AES_KEY_LEN: usize = 16;
/// Hash algorithm negotiated between client and service. Numeric variants
/// match the case-insensitive string values returned by
/// `AsbRegistry.GetCryptoParameters` (`cs:54` — `"MD5"` / `"SHA1"` /
/// `"SHA512"`). Anything else falls through to the .NET branch at `cs:91`
/// (`HMAC-SHA1` only when `forceHmac` is set, otherwise no signing).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashAlgorithm {
Md5,
Sha1,
Sha512,
/// Unknown algorithm — `Sign` returns no MAC unless `force_hmac` is set,
/// in which case HMAC-SHA1 is used. Mirrors `cs:91`.
Unrecognised,
}
impl HashAlgorithm {
/// Parse the `HashAlgorthim` string from `AsbSolutionCryptoParameters`
/// case-insensitively. Note the typo in the registry value name
/// (`HashAlgorthim` not `HashAlgorithm`) is preserved by .NET; we read
/// whatever the registry stores.
pub fn parse(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"md5" => Self::Md5,
"sha1" => Self::Sha1,
"sha512" => Self::Sha512,
_ => Self::Unrecognised,
}
}
}
/// Solution-level crypto parameters loaded from the registry on .NET, or
/// supplied directly by callers on the Rust side. Mirrors
/// `AsbSolutionCryptoParameters` at `AsbRegistry.cs:64-67`.
#[derive(Debug, Clone)]
pub struct CryptoParameters {
/// 1024-bit DH prime (decimal-encoded).
pub prime_decimal: String,
/// DH generator (decimal-encoded).
pub generator_decimal: String,
/// Negotiated hash algorithm (`HashAlgorthim` from the registry).
pub hash_algorithm: HashAlgorithm,
/// DH private-exponent size in bits. Default `256` per `cs:55`.
pub key_size_bits: u32,
}
impl CryptoParameters {
/// Default prime constant from `AsbRegistry.cs:66` (1024-bit
/// decimal-encoded).
pub const DEFAULT_PRIME_TEXT: &'static str = concat!(
"179769313486231590770839156793787453197860296048756011706444423",
"684197180216158519368947833795864925541502180565485980503646440",
"548199239100050792877003355816639229553136239076508735759914822",
"574862575007425302077447712589550957937778424442426617334727629",
"299387668709205606050270810842907692932019128194",
);
/// Default parameters seen on a stock AVEVA install (`HashAlgorthim=MD5`,
/// `keySize=256`, `Generator=22`).
pub fn defaults() -> Self {
Self {
prime_decimal: Self::DEFAULT_PRIME_TEXT.to_string(),
generator_decimal: "22".to_string(),
hash_algorithm: HashAlgorithm::Md5,
key_size_bits: 256,
}
}
}
/// Authenticator state. Owns the DH private key, the derived crypto-key
/// buffer, and the running message-number counter that `Sign` increments
/// per `ConnectionValidator` (`cs:67`).
pub struct AsbAuthenticator {
prime: BigUint,
private_key: BigUint,
/// `localPublicKey` cached as little-endian + sign-byte normalised
/// .NET-`BigInteger`-equivalent bytes (`cs:34`).
local_public_key: Vec<u8>,
/// UTF-8 bytes of the solution passphrase (`cs:28` — note: .NET
/// `Encoding.UTF8.GetBytes` over a `string` yields UTF-8, even though
/// the passphrase originated as UTF-16 inside DPAPI; we copy that
/// re-encoding here exactly).
solution_passphrase: Zeroizing<Vec<u8>>,
hash_algorithm: HashAlgorithm,
next_message_number: u64,
connection_id: [u8; 16],
/// Set by `accept_connect_response`.
remote_public_key: Option<Vec<u8>>,
/// Toggled by `:V2` lifetime suffix in the connect response. False
/// until then (`cs:43,48`).
use_apollo_signing: bool,
}
impl AsbAuthenticator {
/// Build a new authenticator. Generates a fresh DH private key in the
/// `[1, prime - 1)` range and computes `generator^private_key mod prime`
/// for the local public key (`cs:30-35`).
///
/// `connection_id` is the per-session GUID emitted into every signed
/// `ConnectionValidator`. Callers should pass `Uuid::new_v4().into_bytes()`
/// (or equivalent); we keep the parameter explicit so unit tests can
/// pin the value for fixture round-trips.
pub fn new(
passphrase: &str,
params: &CryptoParameters,
connection_id: [u8; 16],
) -> Result<Self, AuthError> {
let prime = parse_decimal(&params.prime_decimal)?;
let generator = parse_decimal(&params.generator_decimal)?;
if prime.is_zero() {
return Err(AuthError::ZeroPrime);
}
let private_key = generate_private_key(params.key_size_bits, &prime)?;
let public_value = generator.modpow(&private_key, &prime);
let local_public_key = bigint_to_dotnet_bytes(&public_value);
Ok(Self {
prime,
private_key,
local_public_key,
solution_passphrase: Zeroizing::new(passphrase.as_bytes().to_vec()),
hash_algorithm: params.hash_algorithm,
next_message_number: 1,
connection_id,
remote_public_key: None,
use_apollo_signing: false,
})
}
/// 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
}
pub fn local_public_key(&self) -> &[u8] {
&self.local_public_key
}
pub fn use_apollo_signing(&self) -> bool {
self.use_apollo_signing
}
/// Apply `ConnectResponse` state: stash the service public key for
/// shared-secret derivation and decide whether the wire is Apollo
/// (raw-AES) or Baktun (deflate-then-AES) per the `:V2` lifetime
/// suffix at `cs:48`.
pub fn accept_connect_response(
&mut self,
service_public_key: &[u8],
connection_lifetime: Option<&str>,
) {
self.remote_public_key = Some(service_public_key.to_vec());
self.use_apollo_signing = connection_lifetime
.map(|s| s.to_ascii_lowercase().contains(":v2"))
.unwrap_or(false);
}
/// Encrypt `local_public_key || remote_public_key` with the AES key
/// derived from `crypto_key`. Returns `(ciphertext, iv)`. Mirrors
/// `CreateAuthenticationData` at `cs:51-60`.
pub fn create_authentication_data(&self) -> Result<EncryptedBytes, AuthError> {
let remote = self
.remote_public_key
.as_deref()
.ok_or(AuthError::NoRemoteKey)?;
let mut clear: Vec<u8> = Vec::with_capacity(self.local_public_key.len() + remote.len());
clear.extend_from_slice(&self.local_public_key);
clear.extend_from_slice(remote);
let result = self.encrypt(&clear);
clear.zeroize();
result
}
/// Sign the canonical-XML body of a request (`request.ToXml()` in .NET)
/// per `cs:62-82`. Returns the populated `ConnectionValidator` — when
/// no HMAC engine is selected and `force_hmac` is false, the validator
/// is emitted with empty MAC + IV. Caller is responsible for
/// serialising the `ConnectionValidator` into the
/// `http://asb.contracts.headers/20111111` SOAP header.
///
/// `request_xml_utf8` is the UTF-8 byte representation of the SOAP
/// envelope's *request body* — NOT the framed wire bytes. The .NET
/// reference calls `request.ToXml()` which serialises the message
/// contract through the `XmlSerializer` and we sign exactly that
/// canonical text. Cross-implementation parity therefore requires the
/// Rust SOAP serializer (when F25 lands) to emit identical bytes.
pub fn sign(
&mut self,
request_xml_utf8: &[u8],
force_hmac: bool,
) -> Result<SignedValidator, AuthError> {
let message_number = self.next_message_number;
self.next_message_number = self.next_message_number.wrapping_add(1);
let mut validator = SignedValidator {
connection_id: self.connection_id,
message_number,
mac: Vec::new(),
iv: Vec::new(),
};
if let Some(hash) = self.compute_hmac(request_xml_utf8, force_hmac)? {
let encrypted = self.encrypt(&hash)?;
validator.mac = encrypted.ciphertext;
validator.iv = encrypted.iv;
}
Ok(validator)
}
fn compute_hmac(&self, message: &[u8], force_hmac: bool) -> Result<Option<Vec<u8>>, AuthError> {
let key = self.crypto_key()?;
match self.hash_algorithm {
HashAlgorithm::Md5 => Ok(Some(hmac_compute::<Hmac<Md5>>(&key, message))),
HashAlgorithm::Sha1 => Ok(Some(hmac_compute::<Hmac<Sha1>>(&key, message))),
HashAlgorithm::Sha512 => Ok(Some(hmac_compute::<Hmac<Sha512>>(&key, message))),
HashAlgorithm::Unrecognised if force_hmac => {
Ok(Some(hmac_compute::<Hmac<Sha1>>(&key, message)))
}
HashAlgorithm::Unrecognised => Ok(None),
}
}
fn encrypt(&self, clear: &[u8]) -> Result<EncryptedBytes, AuthError> {
let aes_key = self.derive_aes_key()?;
let mut iv = [0u8; 16];
rand::thread_rng().fill_bytes(&mut iv);
let ciphertext = if self.use_apollo_signing {
aes_cbc_encrypt(&aes_key, &iv, clear)
} else {
let mut deflated = Vec::with_capacity(clear.len());
let mut encoder = DeflateEncoder::new(&mut deflated, Compression::default());
encoder
.write_all(clear)
.map_err(|e| AuthError::Deflate(e.to_string()))?;
encoder
.finish()
.map_err(|e| AuthError::Deflate(e.to_string()))?;
let result = aes_cbc_encrypt(&aes_key, &iv, &deflated);
deflated.zeroize();
result
};
Ok(EncryptedBytes {
ciphertext,
iv: iv.to_vec(),
})
}
fn derive_aes_key(&self) -> Result<Zeroizing<[u8; AES_KEY_LEN]>, AuthError> {
let crypto_key = self.crypto_key()?;
let password_b64 = base64_encode(&crypto_key);
let mut out = Zeroizing::new([0u8; AES_KEY_LEN]);
pbkdf2_hmac::<Sha1>(
password_b64.as_bytes(),
PASSWORD_SALT,
PBKDF2_ITERATIONS,
out.as_mut_slice(),
);
if std::env::var("MX_ASB_TRACE_DERIVE").ok().is_some() {
eprintln!("asb.derive.crypto_key.len={}", crypto_key.len());
let hex: String = crypto_key.iter().map(|b| format!("{b:02X}")).collect();
eprintln!("asb.derive.crypto_key.hex={hex}");
eprintln!("asb.derive.crypto_key.b64={password_b64}");
let aes_hex: String = out.iter().map(|b| format!("{b:02X}")).collect();
eprintln!("asb.derive.aes_key.hex={aes_hex}");
}
Ok(out)
}
/// `shared = remote^private mod prime`, then append the passphrase
/// bytes — `cs:144-150`. Returned as a `Zeroizing` wrapper so the
/// derivation buffer is wiped on drop.
fn crypto_key(&self) -> Result<Zeroizing<Vec<u8>>, AuthError> {
let remote = self
.remote_public_key
.as_deref()
.ok_or(AuthError::NoRemoteKey)?;
let remote_value = bigint_from_dotnet_bytes(remote);
let shared = remote_value.modpow(&self.private_key, &self.prime);
let shared_bytes = bigint_to_dotnet_bytes(&shared);
let mut buf = Vec::with_capacity(shared_bytes.len() + self.solution_passphrase.len());
buf.extend_from_slice(&shared_bytes);
buf.extend_from_slice(&self.solution_passphrase);
Ok(Zeroizing::new(buf))
}
#[cfg(test)]
fn private_key_bytes(&self) -> Vec<u8> {
bigint_to_dotnet_bytes(&self.private_key)
}
}
/// Output of [`AsbAuthenticator::sign`]: the populated `ConnectionValidator`
/// fields exactly matching the .NET `ConnectionValidator` message header
/// shape (`AsbContracts.cs` — `ConnectionId` GUID, `MessageNumber` ulong,
/// `MessageAuthenticationCode` byte[], `SignatureInitializationVector`
/// byte[]).
#[derive(Debug, Clone)]
pub struct SignedValidator {
pub connection_id: [u8; 16],
pub message_number: u64,
pub mac: Vec<u8>,
pub iv: Vec<u8>,
}
/// Output of `create_authentication_data` / per-message encryption.
/// Maps onto the .NET `AuthenticationData { Data, InitializationVector }`
/// contract.
#[derive(Debug, Clone)]
pub struct EncryptedBytes {
pub ciphertext: Vec<u8>,
pub iv: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("invalid decimal big-integer: {0}")]
InvalidDecimal(String),
#[error("DH prime is zero")]
ZeroPrime,
#[error("DH key size {0} is not a positive multiple of 8")]
InvalidKeySize(u32),
#[error("ConnectResponse not yet accepted — service public key unknown")]
NoRemoteKey,
#[error("deflate failed: {0}")]
Deflate(String),
}
// ---- DH helpers ----------------------------------------------------------
/// Generate a DH private key in `[1, prime - 1)` per `cs:153-166`.
/// `key_size_bits / 8 + 1` random bytes are drawn, the high byte forced to
/// zero (so the value stays positive when interpreted as a .NET BigInteger
/// little-endian two's-complement), and the loop retries until the value
/// falls in range.
fn generate_private_key(key_size_bits: u32, prime: &BigUint) -> Result<BigUint, AuthError> {
if key_size_bits == 0 || key_size_bits % 8 != 0 {
return Err(AuthError::InvalidKeySize(key_size_bits));
}
let byte_len = (key_size_bits / 8) as usize + 1;
let prime_minus_one = prime - BigUint::one();
let one = BigUint::one();
let mut buf = vec![0u8; byte_len];
let mut rng = rand::thread_rng();
loop {
rng.fill_bytes(&mut buf);
// Force the .NET sign byte to 0 so the value is unambiguously
// positive (`cs:160`).
if let Some(last) = buf.last_mut() {
*last = 0;
}
let candidate = bigint_from_dotnet_bytes(&buf);
if candidate > one && candidate < prime_minus_one {
buf.zeroize();
return Ok(candidate);
}
}
}
/// Decimal-string → `BigUint`. Used for the registry-supplied prime +
/// generator (`cs:23-24,57`).
fn parse_decimal(value: &str) -> Result<BigUint, AuthError> {
let trimmed = value.trim();
BigUint::parse_bytes(trimmed.as_bytes(), 10)
.ok_or_else(|| AuthError::InvalidDecimal(trimmed.to_string()))
}
/// `BigUint` → .NET `BigInteger.ToByteArray()` little-endian
/// two's-complement bytes.
///
/// `BigUint::to_bytes_le` returns the minimal byte representation. .NET's
/// `BigInteger.ToByteArray` does the same for positive values *except*
/// that when the new MSB has its top bit set, .NET appends a `0x00` sign
/// byte to keep the number unambiguously positive in two's-complement.
/// `BigInteger.Zero.ToByteArray()` == `{ 0 }` per .NET; `BigUint::zero`
/// returns an empty `Vec`, so we promote that case explicitly.
pub fn bigint_to_dotnet_bytes(value: &BigUint) -> Vec<u8> {
if value.is_zero() {
return vec![0u8];
}
let mut bytes = value.to_bytes_le();
if let Some(&last) = bytes.last() {
if last & 0x80 != 0 {
bytes.push(0);
}
}
bytes
}
/// .NET `BigInteger(byte[])` little-endian two's-complement → `BigUint`.
/// Trailing `0x00` sign bytes are absorbed by `from_bytes_le`'s leading-
/// zero handling. ASB DH values are always positive, so we treat any
/// non-zero high bit on the last byte as a non-issue (the .NET sign byte
/// itself is `0x00`, which is what stays after stripping leading zeros).
pub fn bigint_from_dotnet_bytes(bytes: &[u8]) -> BigUint {
BigUint::from_bytes_le(bytes)
}
// ---- Crypto helpers ------------------------------------------------------
fn aes_cbc_encrypt(key: &[u8; AES_KEY_LEN], iv: &[u8; 16], clear: &[u8]) -> Vec<u8> {
type Encryptor = CbcEncryptor<Aes128>;
let cipher = Encryptor::new(key.into(), iv.into());
cipher.encrypt_padded_vec_mut::<aes::cipher::block_padding::Pkcs7>(clear)
}
fn hmac_compute<M: Mac + KeyInit>(key: &[u8], message: &[u8]) -> Vec<u8> {
// HMAC accepts any key length; the `Result` arm is unreachable for
// any of the `Hmac<H>` instantiations we use here. If it ever fires
// (e.g. someone wires this up with a non-HMAC `Mac` impl that has a
// length constraint), return an empty MAC rather than panic — the
// caller will surface the empty MAC to the wire and the service will
// reject it cleanly.
match <M as KeyInit>::new_from_slice(key) {
Ok(mut mac) => {
mac.update(message);
mac.finalize().into_bytes().to_vec()
}
Err(_) => Vec::new(),
}
}
/// Standard base64 encoder (RFC 4648, default `Convert.ToBase64String`
/// semantics — no line breaks, `+` / `/` alphabet, `=` padding).
/// Implemented inline to avoid pulling the `base64` crate as a direct
/// dep when we only need 16 lines of encoder code.
fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// `idx & 0x3F` keeps the index in `0..64`; `.get(idx).copied()` returns
// `Some(_)` for that range so the fallback branch is unreachable but
// satisfies clippy::indexing_slicing.
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
}
// num-integer's `Integer` trait is imported above so `prime - BigUint::one()`
// uses subtraction without wrapping. Silences an unused-import warning when
// we don't directly call any `.gcd()`-style helpers — kept anyway for the
// `Zero`/`One` traits' presence via `num-traits`.
#[allow(dead_code)]
fn _unused_integer_gcd(a: &BigUint, b: &BigUint) -> BigUint {
a.gcd(b)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn parse_decimal_round_trips_default_prime() {
let prime = parse_decimal(CryptoParameters::DEFAULT_PRIME_TEXT).unwrap();
// The default prime is a 300-digit decimal, which works out to
// ~996 bits. The "1024-bit" label in older docs is loose — the
// exact bit length is fixed by the published constant. This pins
// the value so an accidental string edit is caught.
assert_eq!(prime.bits(), 995);
}
#[test]
fn dotnet_byte_round_trip_keeps_sign_byte_for_high_msb() {
let bytes = vec![0xFFu8, 0x00];
let value = bigint_from_dotnet_bytes(&bytes);
let round = bigint_to_dotnet_bytes(&value);
assert_eq!(round, bytes);
}
#[test]
fn dotnet_byte_round_trip_skips_sign_byte_when_high_bit_clear() {
let bytes = vec![0x7Fu8];
let value = bigint_from_dotnet_bytes(&bytes);
let round = bigint_to_dotnet_bytes(&value);
assert_eq!(round, bytes);
}
#[test]
fn dotnet_byte_round_trip_zero() {
let bytes = vec![0u8];
let value = bigint_from_dotnet_bytes(&bytes);
let round = bigint_to_dotnet_bytes(&value);
assert_eq!(round, bytes);
}
#[test]
fn base64_encode_matches_dotnet() {
// Spot-check vs `Convert.ToBase64String(new byte[]{1,2,3})` => "AQID"
assert_eq!(base64_encode(&[1, 2, 3]), "AQID");
assert_eq!(base64_encode(&[1, 2]), "AQI=");
assert_eq!(base64_encode(&[1]), "AQ==");
assert_eq!(base64_encode(&[]), "");
// RFC 4648 §10
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn authenticator_emits_local_public_key_in_dh_range() {
let params = CryptoParameters::defaults();
let auth = AsbAuthenticator::new("test-passphrase", &params, [0u8; 16]).unwrap();
// Local public key is `g^x mod p` for some `x ∈ [1, p-1)`. With
// `g=22` and a 256-bit `x`, the result must be at least 1 byte
// and at most as wide as `p` (~129 bytes including the sign byte).
let pk = auth.local_public_key();
assert!(!pk.is_empty(), "public key must not be empty");
assert!(
pk.len() <= 129,
"public key longer than 1024-bit prime + sign byte"
);
}
#[test]
fn authenticator_private_key_size_respects_key_size_bits() {
let params = CryptoParameters::defaults();
let auth = AsbAuthenticator::new("test-passphrase", &params, [0u8; 16]).unwrap();
let pk = auth.private_key_bytes();
// 256-bit key → at most 33 bytes (32 raw + 1 sign byte; .NET
// generator clears the high byte so the sign byte never fires
// for this size, but allow it as the upper bound).
assert!(pk.len() <= 33);
}
#[test]
fn dh_shared_secret_matches_between_two_peers() {
// Cross-check: two peers with the same parameters, exchanging
// public keys, derive the same shared `crypto_key` prefix.
let params = CryptoParameters::defaults();
let mut alice = AsbAuthenticator::new("solution", &params, [1u8; 16]).unwrap();
let mut bob = AsbAuthenticator::new("solution", &params, [2u8; 16]).unwrap();
let alice_pub = alice.local_public_key().to_vec();
let bob_pub = bob.local_public_key().to_vec();
alice.accept_connect_response(&bob_pub, None);
bob.accept_connect_response(&alice_pub, None);
let alice_key = alice.crypto_key().unwrap();
let bob_key = bob.crypto_key().unwrap();
assert_eq!(&alice_key[..], &bob_key[..]);
}
#[test]
fn signed_validator_increments_message_number() {
let params = CryptoParameters::defaults();
let mut alice = AsbAuthenticator::new("solution", &params, [1u8; 16]).unwrap();
let bob = AsbAuthenticator::new("solution", &params, [2u8; 16]).unwrap();
alice.accept_connect_response(bob.local_public_key(), None);
let v1 = alice.sign(b"<request/>", false).unwrap();
let v2 = alice.sign(b"<request/>", false).unwrap();
assert_eq!(v1.message_number, 1);
assert_eq!(v2.message_number, 2);
assert_eq!(v1.connection_id, [1u8; 16]);
}
#[test]
fn aes_cbc_encrypt_pkcs7_round_trips_against_test_vector() {
// Empty plaintext → 16-byte PKCS7-padded ciphertext.
let key = [0u8; 16];
let iv = [0u8; 16];
let ct = aes_cbc_encrypt(&key, &iv, &[]);
assert_eq!(ct.len(), 16);
}
#[test]
fn unrecognised_hash_algorithm_skips_mac_unless_forced() {
let params = CryptoParameters {
hash_algorithm: HashAlgorithm::Unrecognised,
..CryptoParameters::defaults()
};
let mut alice = AsbAuthenticator::new("s", &params, [1u8; 16]).unwrap();
let bob = AsbAuthenticator::new("s", &params, [2u8; 16]).unwrap();
alice.accept_connect_response(bob.local_public_key(), None);
let unsigned = alice.sign(b"<x/>", false).unwrap();
assert!(
unsigned.mac.is_empty(),
"unrecognised algorithm should skip MAC"
);
let signed = alice.sign(b"<x/>", true).unwrap();
assert!(!signed.mac.is_empty(), "force_hmac=true must produce a MAC");
}
#[test]
fn apollo_signing_toggles_with_v2_lifetime_suffix() {
let params = CryptoParameters::defaults();
let mut alice = AsbAuthenticator::new("s", &params, [1u8; 16]).unwrap();
let bob = AsbAuthenticator::new("s", &params, [2u8; 16]).unwrap();
alice.accept_connect_response(bob.local_public_key(), Some("PT5M:V2"));
assert!(alice.use_apollo_signing());
let mut alice2 = AsbAuthenticator::new("s", &params, [1u8; 16]).unwrap();
alice2.accept_connect_response(bob.local_public_key(), Some("PT5M"));
assert!(!alice2.use_apollo_signing());
let mut alice3 = AsbAuthenticator::new("s", &params, [1u8; 16]).unwrap();
alice3.accept_connect_response(bob.local_public_key(), None);
assert!(!alice3.use_apollo_signing());
}
#[test]
fn pbkdf2_derive_matches_dotnet_test_vector() {
// .NET reference vector — captured by running `Rfc2898DeriveBytes.Pbkdf2`
// with password=base64("hello") = "aGVsbG8=", salt="ArchestrAService",
// 1000 iterations, SHA1, 16-byte output. Cross-check ensures the
// `password_b64 || salt || iterations || output_len` recipe matches
// .NET exactly.
//
// To regenerate (PowerShell):
// $pw = [Convert]::ToBase64String([byte[]](104,101,108,108,111))
// $salt = [System.Text.Encoding]::ASCII.GetBytes("ArchestrAService")
// [BitConverter]::ToString(
// [System.Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2(
// $pw, $salt, 1000, "SHA1", 16))
//
// Until that command is run on a Windows host with .NET 10, this
// test only proves *self-consistency* — it pins the Rust output so
// any unintended algorithm change is caught.
let mut out = [0u8; AES_KEY_LEN];
let password_b64 = base64_encode(b"hello");
pbkdf2_hmac::<Sha1>(
password_b64.as_bytes(),
PASSWORD_SALT,
PBKDF2_ITERATIONS,
&mut out,
);
// Computed by running this exact code once and pinning the result.
// Replace with the .NET `BitConverter.ToString(...)` output once
// the cross-implementation parity probe lands.
let snapshot = hex::decode("8eece598d3cd62ebfcb0605c8822f3ce").unwrap();
// Self-consistency snapshot, not a .NET-verified vector. If a
// real cross-impl vector comes later, replace the bytes inline.
assert_eq!(out.as_slice(), snapshot.as_slice());
}
/// End-to-end byte-equality test against a `.NET reference fixture
/// captured via `MxAsbClient.Probe --dump-deterministic-hmac`. All
/// inputs (passphrase, prime, generator, private-key bytes, remote
/// public-key bytes, message number, connection ID, AES IV,
/// consumer-data + IV bytes) are pinned, so this test reproduces
/// .NET's exact output for:
///
/// 1. `shared = remote_pub^private_key mod prime`
/// 2. `crypto_key = shared || passphrase_utf8`
/// 3. `hmac = HMAC-SHA1(crypto_key, xml_utf8)` where `xml_utf8` is
/// the canonical XML emitted by .NET's `XmlSerializer` (decoded
/// from the fixture's `xml_utf8_b64`).
/// 4. `aes_key = PBKDF2-SHA1(base64(crypto_key),
/// "ArchestrAService", 1000, 16)`
/// 5. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7)`
///
/// If any step diverges from the .NET reference, this test localises
/// the bug — without depending on session randomness (which is what
/// makes the live AuthenticateMe failure so hard to diagnose).
///
/// **Important:** the canonical XML byte-equality is verified
/// separately by `mxaccess-asb::xml_canonical::tests` against the
/// `signed-xml/*.xml` fixtures. Here we just consume the
/// `.NET-supplied XML bytes from the fixture so a Rust XML emitter
/// bug doesn't mask a Rust crypto bug (or vice versa).
#[test]
fn deterministic_hmac_matches_dotnet_fixture() {
use hmac::Hmac;
use sha1::Sha1;
let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/deterministic-hmac/authenticate-me.kv");
let raw = std::fs::read_to_string(&fixture_path).unwrap_or_else(|e| {
panic!("could not read fixture {}: {e}", fixture_path.display())
});
let kv = parse_kv(&raw);
let prime_decimal = kv.get("prime_decimal").expect("prime_decimal");
let private_key_hex = kv.get("private_key_hex").expect("private_key_hex");
let remote_pub_hex = kv.get("remote_pub_hex").expect("remote_pub_hex");
let passphrase = kv.get("passphrase").expect("passphrase");
let consumer_data_hex = kv.get("consumer_data_hex").expect("consumer_data_hex");
let consumer_iv_hex = kv.get("consumer_iv_hex").expect("consumer_iv_hex");
let aes_iv_hex = kv.get("aes_iv_hex").expect("aes_iv_hex");
let expected_shared_hex = kv.get("shared_secret_hex").expect("shared_secret_hex");
let expected_crypto_key_hex = kv.get("crypto_key_hex").expect("crypto_key_hex");
let expected_xml_b64 = kv.get("xml_utf8_b64").expect("xml_utf8_b64");
let expected_hmac_hex = kv.get("hmac_sha1_hex").expect("hmac_sha1_hex");
let expected_aes_key_hex = kv.get("aes_key_hex").expect("aes_key_hex");
let expected_encrypted_mac_hex =
kv.get("encrypted_mac_hex").expect("encrypted_mac_hex");
// Step 1 — shared = remote_pub^private mod prime
let prime = parse_decimal(prime_decimal).unwrap();
let private_key_bytes = hex::decode(private_key_hex).unwrap();
let remote_pub_bytes = hex::decode(remote_pub_hex).unwrap();
let private_key_value = bigint_from_dotnet_bytes(&private_key_bytes);
let remote_pub_value = bigint_from_dotnet_bytes(&remote_pub_bytes);
let shared_value = remote_pub_value.modpow(&private_key_value, &prime);
let shared_bytes = bigint_to_dotnet_bytes(&shared_value);
assert_eq!(
hex::encode_upper(&shared_bytes),
*expected_shared_hex,
"shared_secret bytes diverge from .NET (DH math mismatch — \
check parse_decimal, bigint_from/to_dotnet_bytes, modpow)"
);
// Step 2 — crypto_key = shared || passphrase_utf8
let mut crypto_key = shared_bytes.clone();
crypto_key.extend_from_slice(passphrase.as_bytes());
assert_eq!(
hex::encode_upper(&crypto_key),
*expected_crypto_key_hex,
"crypto_key concatenation diverges (likely passphrase \
encoding bug — .NET uses Encoding.UTF8.GetBytes)"
);
// Step 3 — HMAC-SHA1(crypto_key, xml_utf8)
let xml_bytes = base64_decode_strict(expected_xml_b64);
let actual_hmac = hmac_compute::<Hmac<Sha1>>(&crypto_key, &xml_bytes);
assert_eq!(
hex::encode_upper(&actual_hmac),
*expected_hmac_hex,
"HMAC-SHA1 output diverges — Rust hmac::Hmac<Sha1> does \
NOT match .NET's HMACSHA1 for the same (key, message)"
);
// Step 4 — AES key = PBKDF2-SHA1(base64(crypto_key), salt, 1000, 16)
let password_b64 = base64_encode(&crypto_key);
let mut aes_key = [0u8; AES_KEY_LEN];
pbkdf2_hmac::<Sha1>(
password_b64.as_bytes(),
PASSWORD_SALT,
PBKDF2_ITERATIONS,
&mut aes_key,
);
assert_eq!(
hex::encode_upper(aes_key),
*expected_aes_key_hex,
"PBKDF2-SHA1(base64(crypto_key)) diverges — likely a salt \
or iteration-count mismatch, or password is being byte- \
encoded differently from .NET's `Convert.ToBase64String`"
);
// Step 5 — AES-CBC encrypt(hmac) with fixed IV
let aes_iv_bytes = hex::decode(aes_iv_hex).unwrap();
let aes_iv: [u8; 16] = aes_iv_bytes.try_into().expect("aes_iv must be 16 bytes");
let encrypted_mac = aes_cbc_encrypt(&aes_key, &aes_iv, &actual_hmac);
assert_eq!(
hex::encode_upper(&encrypted_mac),
*expected_encrypted_mac_hex,
"AES-CBC encrypt diverges — could be a PKCS7 padding bug, \
a key-length mismatch, or a cipher-suite drift"
);
// Sanity assertions to catch fixture corruption.
assert_eq!(consumer_data_hex.len(), 208 * 2, "fixture consumer data");
assert_eq!(consumer_iv_hex.len(), 16 * 2, "fixture consumer iv");
}
fn parse_kv(text: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
out.insert(key.trim().to_string(), value.trim().to_string());
}
}
out
}
/// Strict standard-base64 decoder. Mirrors .NET's
/// `Convert.FromBase64String` for the subset of inputs this test
/// uses (no line wrapping, standard alphabet, padding required).
fn base64_decode_strict(s: &str) -> Vec<u8> {
let trimmed: String = s.chars().filter(|c| !c.is_whitespace()).collect();
if trimmed.len() % 4 != 0 {
panic!("base64 input length {} not multiple of 4", trimmed.len());
}
const VAL: [i8; 256] = build_b64_table();
let mut out = Vec::with_capacity(trimmed.len() / 4 * 3);
let bytes = trimmed.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c0 = bytes[i];
let c1 = bytes[i + 1];
let c2 = bytes[i + 2];
let c3 = bytes[i + 3];
let v0 = VAL[c0 as usize];
let v1 = VAL[c1 as usize];
let v2 = if c2 == b'=' { 0 } else { VAL[c2 as usize] };
let v3 = if c3 == b'=' { 0 } else { VAL[c3 as usize] };
assert!(v0 >= 0 && v1 >= 0 && v2 >= 0 && v3 >= 0, "invalid b64 char");
let triple = ((v0 as u32) << 18)
| ((v1 as u32) << 12)
| ((v2 as u32) << 6)
| (v3 as u32);
out.push((triple >> 16) as u8);
if c2 != b'=' {
out.push((triple >> 8) as u8);
}
if c3 != b'=' {
out.push(triple as u8);
}
i += 4;
}
out
}
const fn build_b64_table() -> [i8; 256] {
let mut t = [-1i8; 256];
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0;
while i < alphabet.len() {
t[alphabet[i] as usize] = i as i8;
i += 1;
}
t
}
}
+18 -1
View File
@@ -1,7 +1,9 @@
//! `mxaccess-asb-nettcp` — `[MS-NMF]` framing + `[MC-NBFX]/[MC-NBFS]` binary
//! message encoding (the default `NetTcpBinding` encoder, **not** SOAP/XML).
//!
//! M0 stub. Real implementation lands in M5 — see `design/60-roadmap.md`.
//! M5 work-in-progress — see `design/60-roadmap.md` and follow-up F18 in
//! `design/followups.md` for the current sub-stream breakdown.
//!
//! The .NET reference at `src/MxAsbClient/MxAsbDataClient.cs:660-685` uses
//! `new NetTcpBinding(SecurityMode.None)` with no encoder override, which
//! selects `BinaryMessageEncodingBindingElement` by default.
@@ -11,5 +13,20 @@
//! plus the reliable-session ack handling on the underlying `net.tcp` channel.
//! 2. `[MC-NBFX]` binary XML + `[MC-NBFS]` static dictionary that holds the
//! SOAP/WS-Addressing/`IASBIDataV2`-action strings.
//!
//! …plus an [`auth`] sub-module that ports the .NET `AsbSystemAuthenticator`
//! (DH key exchange + HMAC signing + AES-128/PBKDF2-SHA1 derivation).
#![forbid(unsafe_code)]
pub mod auth;
pub mod nbfs;
pub mod nbfx;
pub mod nmf;
pub use auth::AuthError;
pub use nbfs::{StaticEntry, lookup_static, position_of_static};
pub use nbfx::{
DynamicDictionary, NbfxError, NbfxName, NbfxText, NbfxToken, decode_tokens, encode_tokens,
};
pub use nmf::{NmfEncoding, NmfError, NmfMode, NmfRecord, NmfRecordType};
+547
View File
@@ -0,0 +1,547 @@
//! `[MC-NBFS]` static dictionary table for `[MC-NBFX]` binary XML.
//!
//! The .NET binary message encoder (`BinaryMessageEncodingBindingElement`,
//! the default for `NetTcpBinding`) compresses common strings — SOAP /
//! WS-Addressing tokens, URIs, frequently-used element/attribute names —
//! by encoding them as a single `Multibyte Int31` index into a
//! globally-known static dictionary. `[MC-NBFS]` §2.2 enumerates that
//! dictionary; the official table has 487 entries, all ASCII.
//!
//! ## Scope of this port
//!
//! The full table is bounded but tedious. This module ships the
//! **proven subset** — the SOAP, WS-Addressing, and `xsi`/`xsd`/`xsd:type`
//! tokens we have observed in captured ASB messages
//! (`analysis/proxy/mxasbclient-*`). Lookups against unmapped IDs
//! return `None`; the NBFX decoder surfaces that as a typed
//! `UnknownStaticDictionaryId` error so the caller knows to extend the
//! table or fall through to the inline-string path.
//!
//! Adding more entries is a one-line edit: append a `(id, &str)` row to
//! [`STATIC_ENTRIES`] in numerical order. The existing tests assert
//! monotonic IDs to catch transposition bugs.
//!
//! ## What the table is NOT
//!
//! ASB-specific contract strings (`"http://ASB.IDataV2"`,
//! `"http://asb.contracts/20111111"`, the operation names, etc.) are
//! **not** in the static dictionary. They live in the per-session
//! *dynamic* dictionary that `[MC-NBFX]` builds up via the
//! `DictionaryString` records (record bytes `0x42`/`0x43`/`0x44`/`0x45`
//! in `[MC-NBFX]` §2.2). The dynamic dictionary is mutable per session
//! and lives in the F21 NBFX codec.
use std::collections::HashMap;
use std::sync::OnceLock;
/// One static-dictionary entry.
#[derive(Debug, Clone, Copy)]
pub struct StaticEntry {
pub id: u32,
pub value: &'static str,
}
/// Curated subset of the `[MC-NBFS]` §2.2 static dictionary. Sorted by
/// numerical `id`; extending the table is a matter of appending rows in
/// the right slot. Source for every entry: the public `[MC-NBFS]` §2.2
/// table (Microsoft publishes the full list).
///
/// **Coverage:** SOAP 1.2 envelope tokens, WS-Addressing 1.0 tokens,
/// XML Schema Instance + xsi:type primitives, common element / attribute
/// names. Approximately ~80 entries — the subset captured in
/// `analysis/proxy/mxasbclient-*` shows up here.
pub const STATIC_ENTRIES: &[StaticEntry] = &[
StaticEntry {
id: 0,
value: "mustUnderstand",
},
StaticEntry {
id: 2,
value: "Envelope",
},
StaticEntry {
id: 4,
value: "http://www.w3.org/2003/05/soap-envelope",
},
StaticEntry {
id: 6,
value: "http://www.w3.org/2005/08/addressing",
},
StaticEntry {
id: 8,
value: "Header",
},
StaticEntry {
id: 10,
value: "Action",
},
StaticEntry {
id: 12,
value: "To",
},
StaticEntry {
id: 14,
value: "Body",
},
StaticEntry {
id: 16,
value: "Algorithm",
},
StaticEntry {
id: 18,
value: "RelatesTo",
},
StaticEntry {
id: 20,
value: "http://www.w3.org/2005/08/addressing/anonymous",
},
StaticEntry {
id: 22,
value: "URI",
},
StaticEntry {
id: 24,
value: "Reference",
},
StaticEntry {
id: 26,
value: "MessageID",
},
StaticEntry {
id: 28,
value: "Id",
},
StaticEntry {
id: 30,
value: "Identifier",
},
StaticEntry {
id: 32,
value: "http://schemas.xmlsoap.org/ws/2005/02/rm",
},
StaticEntry {
id: 34,
value: "Transforms",
},
StaticEntry {
id: 36,
value: "Transform",
},
StaticEntry {
id: 38,
value: "DigestMethod",
},
StaticEntry {
id: 40,
value: "DigestValue",
},
StaticEntry {
id: 42,
value: "Address",
},
StaticEntry {
id: 44,
value: "ReplyTo",
},
StaticEntry {
id: 46,
value: "SequenceAcknowledgement",
},
StaticEntry {
id: 48,
value: "AcknowledgementRange",
},
StaticEntry {
id: 50,
value: "Upper",
},
StaticEntry {
id: 52,
value: "Lower",
},
StaticEntry {
id: 54,
value: "BufferRemaining",
},
StaticEntry {
id: 56,
value: "http://schemas.microsoft.com/ws/2006/05/rm",
},
StaticEntry {
id: 58,
value: "http://schemas.xmlsoap.org/ws/2005/02/rm/SequenceAcknowledgement",
},
StaticEntry {
id: 60,
value: "SecurityTokenReference",
},
StaticEntry {
id: 62,
value: "Sequence",
},
StaticEntry {
id: 64,
value: "MessageNumber",
},
StaticEntry {
id: 66,
value: "http://www.w3.org/2000/09/xmldsig#",
},
StaticEntry {
id: 68,
value: "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
},
StaticEntry {
id: 70,
value: "KeyInfo",
},
StaticEntry {
id: 72,
value: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd",
},
StaticEntry {
id: 74,
value: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd",
},
StaticEntry {
id: 76,
value: "Created",
},
StaticEntry {
id: 78,
value: "Expires",
},
StaticEntry {
id: 80,
value: "Length",
},
StaticEntry {
id: 82,
value: "Nonce",
},
StaticEntry {
id: 84,
value: "Timestamp",
},
StaticEntry {
id: 86,
value: "TokenType",
},
StaticEntry {
id: 88,
value: "Usage",
},
StaticEntry {
id: 90,
value: "SecureChannelToken",
},
StaticEntry {
id: 92,
value: "RequestSecurityTokenResponse",
},
StaticEntry {
id: 94,
value: "TokenType",
},
StaticEntry {
id: 96,
value: "RequestedSecurityToken",
},
StaticEntry {
id: 98,
value: "RequestedAttachedReference",
},
StaticEntry {
id: 100,
value: "RequestedUnattachedReference",
},
StaticEntry {
id: 102,
value: "RequestedProofToken",
},
StaticEntry {
id: 104,
value: "ComputedKey",
},
StaticEntry {
id: 106,
value: "Entropy",
},
StaticEntry {
id: 108,
value: "BinarySecret",
},
StaticEntry {
id: 110,
value: "http://schemas.microsoft.com/ws/2006/02/transactions",
},
StaticEntry {
id: 112,
value: "s",
},
StaticEntry {
id: 114,
value: "Fault",
},
StaticEntry {
id: 116,
value: "MustUnderstand",
},
StaticEntry {
id: 118,
value: "role",
},
StaticEntry {
id: 120,
value: "relay",
},
StaticEntry {
id: 122,
value: "Code",
},
StaticEntry {
id: 124,
value: "Reason",
},
StaticEntry {
id: 126,
value: "Text",
},
StaticEntry {
id: 128,
value: "Node",
},
StaticEntry {
id: 130,
value: "Role",
},
StaticEntry {
id: 132,
value: "Detail",
},
StaticEntry {
id: 134,
value: "Value",
},
StaticEntry {
id: 136,
value: "Subcode",
},
StaticEntry {
id: 138,
value: "NotUnderstood",
},
StaticEntry {
id: 140,
value: "qname",
},
StaticEntry { id: 142, value: "" },
StaticEntry {
id: 144,
value: "From",
},
StaticEntry {
id: 146,
value: "FaultTo",
},
StaticEntry {
id: 148,
value: "EndpointReference",
},
StaticEntry {
id: 150,
value: "PortType",
},
StaticEntry {
id: 152,
value: "ServiceName",
},
StaticEntry {
id: 154,
value: "PortName",
},
StaticEntry {
id: 156,
value: "ReferenceProperties",
},
StaticEntry {
id: 158,
value: "RelationshipType",
},
StaticEntry {
id: 160,
value: "Reply",
},
StaticEntry {
id: 162,
value: "a",
},
StaticEntry {
id: 164,
value: "http://schemas.xmlsoap.org/ws/2006/02/addressingidentity",
},
StaticEntry {
id: 166,
value: "Identity",
},
StaticEntry {
id: 168,
value: "Spn",
},
StaticEntry {
id: 170,
value: "Upn",
},
StaticEntry {
id: 172,
value: "Rsa",
},
StaticEntry {
id: 174,
value: "Dns",
},
StaticEntry {
id: 176,
value: "X509v3Certificate",
},
StaticEntry {
id: 178,
value: "http://www.w3.org/2005/08/addressing/fault",
},
StaticEntry {
id: 180,
value: "ReferenceParameters",
},
StaticEntry {
id: 182,
value: "IsReferenceParameter",
},
// xsi / xsd primitives — used heavily by the .NET XmlSerializer for
// serialised value types in custom message-contract bodies.
StaticEntry {
id: 436,
value: "type",
},
StaticEntry {
id: 438,
value: "i",
},
StaticEntry {
id: 440,
value: "http://www.w3.org/2001/XMLSchema-instance",
},
StaticEntry {
id: 442,
value: "http://www.w3.org/2001/XMLSchema",
},
StaticEntry {
id: 444,
value: "nil",
},
];
/// Lookup an entry by static-dictionary ID. Returns `None` for IDs
/// outside the curated subset; callers should treat that as "unknown
/// static ID" and either extend [`STATIC_ENTRIES`] or fall through to
/// the inline-string path.
pub fn lookup_static(id: u32) -> Option<&'static str> {
STATIC_ENTRIES
.binary_search_by_key(&id, |e| e.id)
.ok()
.and_then(|idx| STATIC_ENTRIES.get(idx).map(|e| e.value))
}
/// Reverse lookup — find the static-dictionary ID for a string. Returns
/// `None` for strings not in the curated subset; encoders can either
/// extend [`STATIC_ENTRIES`] or fall through to the inline-string /
/// dynamic-dictionary path.
pub fn position_of_static(value: &str) -> Option<u32> {
static REVERSE: OnceLock<HashMap<&'static str, u32>> = OnceLock::new();
let map = REVERSE.get_or_init(|| {
let mut map = HashMap::with_capacity(STATIC_ENTRIES.len());
for entry in STATIC_ENTRIES {
// First-id-wins for duplicates (the .NET dictionary has
// entries 86 + 94 = "TokenType"; we lock the lower id so
// round-trip lookups are deterministic).
map.entry(entry.value).or_insert(entry.id);
}
map
});
map.get(value).copied()
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn static_entries_have_monotonic_ids() {
let mut last = None;
for entry in STATIC_ENTRIES {
if let Some(prev) = last {
assert!(
entry.id > prev,
"static dictionary entries must be sorted by id; saw {prev} then {}",
entry.id
);
}
last = Some(entry.id);
}
}
#[test]
fn lookup_returns_known_entries() {
assert_eq!(lookup_static(0), Some("mustUnderstand"));
assert_eq!(lookup_static(2), Some("Envelope"));
assert_eq!(
lookup_static(4),
Some("http://www.w3.org/2003/05/soap-envelope")
);
assert_eq!(
lookup_static(440),
Some("http://www.w3.org/2001/XMLSchema-instance")
);
}
#[test]
fn lookup_returns_none_for_unmapped_ids() {
assert_eq!(lookup_static(1), None); // odd ids are namespace pairs we don't include
assert_eq!(lookup_static(999_999), None);
}
#[test]
fn position_of_known_strings_is_consistent_with_lookup() {
for entry in STATIC_ENTRIES {
// Two entries with the same string ("TokenType" at 86 and 94)
// collapse to the lower id by `or_insert`. Skip those for
// the strict round-trip assertion; reverse-lookup of the
// duplicate string is allowed to map to any of its ids.
let id = position_of_static(entry.value).unwrap();
assert!(
id <= entry.id,
"position_of returned a higher id than the entry"
);
assert_eq!(lookup_static(id), Some(entry.value));
}
}
#[test]
fn position_of_unknown_strings_is_none() {
assert_eq!(position_of_static("not-in-table"), None);
assert_eq!(position_of_static("http://ASB.IDataV2"), None);
}
#[test]
fn empty_string_round_trips_to_id_142() {
// Position 142 in the spec is the empty string. Sanity-check
// we got the right slot.
assert_eq!(lookup_static(142), Some(""));
assert_eq!(position_of_static(""), Some(142));
}
}
File diff suppressed because it is too large Load Diff
+676
View File
@@ -0,0 +1,676 @@
//! `[MS-NMF]` `.NET Message Framing` record codec.
//!
//! Implements the record types `[MS-NMF]` §2.2 enumerates over a
//! `net.tcp` channel:
//!
//! | Byte | Record | Body |
//! |------|-------------------------|-----------------------------------------------------|
//! | 0x00 | `VersionRecord` | major (`u8`), minor (`u8`) |
//! | 0x01 | `ModeRecord` | mode (`u8` — Singleton/Duplex/Simplex/...) |
//! | 0x02 | `ViaRecord` | `Multibyte Int31` length + UTF-8 URI |
//! | 0x03 | `KnownEncodingRecord` | encoding (`u8`) |
//! | 0x04 | `ExtensibleEncoding` | length-prefixed encoding name |
//! | 0x05 | `UnsizedEnvelopeRecord` | unbounded payload, terminated by `EndRecord` |
//! | 0x06 | `SizedEnvelopeRecord` | `Multibyte Int31` length + payload bytes |
//! | 0x07 | `EndRecord` | (no body) |
//! | 0x08 | `FaultRecord` | `Multibyte Int31` length + UTF-8 fault string |
//! | 0x09 | `UpgradeRequestRecord` | length + UTF-8 upgrade name (e.g. SSL/TLS) |
//! | 0x0A | `UpgradeResponseRecord` | (no body) |
//! | 0x0B | `PreambleAckRecord` | (no body) |
//! | 0x0C | `PreambleEndRecord` | (no body) |
//!
//! Length fields are encoded as `Multibyte Int31` (`[MS-NMF]` §2.2.2.1):
//! 7-bit groups, MSB signals continuation, max 5 bytes (LEB128 unsigned
//! over `i32`).
//!
//! No I/O. Encoders write into a `Vec<u8>`; decoders parse from a `&[u8]`
//! slice and return the consumed-byte count alongside the record. Higher-
//! level `connect`/`request`/`response` flows stay in the M5 ASB client
//! (`mxaccess-asb`) — this module is a pure codec.
//!
//! Source for the on-the-wire shape: WCF wraps the framing inside its
//! `BinaryMessageEncodingBindingElement` (selected by default for the
//! `NetTcpBinding(SecurityMode.None)` at
//! `src/MxAsbClient/MxAsbDataClient.cs:660-685`); the framing itself is
//! the `[MS-NMF]` spec, not a project-specific extension. Captured wire
//! traces under `analysis/proxy/mxasbclient-*` confirm the proven record
//! sequence (Version → Mode → Via → KnownEncoding → PreambleEnd →
//! PreambleAck → SizedEnvelope* → End).
use crate::AuthError; // re-imported into the same crate from auth.rs
use thiserror::Error;
/// Record type bytes per `[MS-NMF]` §2.2.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NmfRecordType {
Version = 0x00,
Mode = 0x01,
Via = 0x02,
KnownEncoding = 0x03,
ExtensibleEncoding = 0x04,
UnsizedEnvelope = 0x05,
SizedEnvelope = 0x06,
End = 0x07,
Fault = 0x08,
UpgradeRequest = 0x09,
UpgradeResponse = 0x0A,
PreambleAck = 0x0B,
PreambleEnd = 0x0C,
}
impl NmfRecordType {
pub fn from_u8(b: u8) -> Option<Self> {
match b {
0x00 => Some(Self::Version),
0x01 => Some(Self::Mode),
0x02 => Some(Self::Via),
0x03 => Some(Self::KnownEncoding),
0x04 => Some(Self::ExtensibleEncoding),
0x05 => Some(Self::UnsizedEnvelope),
0x06 => Some(Self::SizedEnvelope),
0x07 => Some(Self::End),
0x08 => Some(Self::Fault),
0x09 => Some(Self::UpgradeRequest),
0x0A => Some(Self::UpgradeResponse),
0x0B => Some(Self::PreambleAck),
0x0C => Some(Self::PreambleEnd),
_ => None,
}
}
}
/// `ModeRecord` body byte (`[MS-NMF]` §2.2.3.2). The values match the WCF
/// `MessageEncodingMode` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NmfMode {
Singleton = 0x01,
Duplex = 0x02,
Simplex = 0x03,
SingletonSized = 0x04,
}
impl NmfMode {
pub fn from_u8(b: u8) -> Option<Self> {
match b {
0x01 => Some(Self::Singleton),
0x02 => Some(Self::Duplex),
0x03 => Some(Self::Simplex),
0x04 => Some(Self::SingletonSized),
_ => None,
}
}
}
/// `KnownEncodingRecord` body byte (`[MS-NMF]` §2.2.3.4). ASB uses
/// `BinaryWithDictionary` (`0x08`) — the WCF `BinaryMessageEncoder`
/// referencing `[MC-NBFX]` + `[MC-NBFS]`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NmfEncoding {
Utf8SoapText = 0x00,
Utf16SoapText = 0x01,
Utf16LeSoapText = 0x02,
Binary = 0x03,
BinaryWithMtom = 0x04,
Mtom = 0x07,
BinaryWithDictionary = 0x08,
}
impl NmfEncoding {
pub fn from_u8(b: u8) -> Option<Self> {
match b {
0x00 => Some(Self::Utf8SoapText),
0x01 => Some(Self::Utf16SoapText),
0x02 => Some(Self::Utf16LeSoapText),
0x03 => Some(Self::Binary),
0x04 => Some(Self::BinaryWithMtom),
0x07 => Some(Self::Mtom),
0x08 => Some(Self::BinaryWithDictionary),
_ => None,
}
}
}
/// Decoded NMF record body. Encoders accept this type; decoders return it
/// alongside the consumed byte count.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NmfRecord {
Version {
major: u8,
minor: u8,
},
Mode(NmfMode),
/// Via URI bytes — UTF-8. The .NET reference uses `Encoding.UTF8` for
/// the via string (`net.tcp://...`).
Via(String),
KnownEncoding(NmfEncoding),
/// Length-prefixed UTF-8 encoding name for non-`KnownEncoding` cases
/// (`[MS-NMF]` §2.2.3.5). Currently unused by ASB but round-tripped.
ExtensibleEncoding(String),
/// Unbounded payload that streams between this record and the next
/// `EndRecord`. Caller is responsible for chunking.
UnsizedEnvelope(Vec<u8>),
/// Length-prefixed payload (the proven ASB request/reply form).
SizedEnvelope(Vec<u8>),
End,
Fault(String),
UpgradeRequest(String),
UpgradeResponse,
PreambleAck,
PreambleEnd,
}
impl NmfRecord {
/// Encode to wire bytes; appends to `out`.
pub fn encode_into(&self, out: &mut Vec<u8>) -> Result<(), NmfError> {
match self {
Self::Version { major, minor } => {
out.push(NmfRecordType::Version as u8);
out.push(*major);
out.push(*minor);
}
Self::Mode(mode) => {
out.push(NmfRecordType::Mode as u8);
out.push(*mode as u8);
}
Self::Via(uri) => {
out.push(NmfRecordType::Via as u8);
encode_string(out, uri.as_bytes())?;
}
Self::KnownEncoding(enc) => {
out.push(NmfRecordType::KnownEncoding as u8);
out.push(*enc as u8);
}
Self::ExtensibleEncoding(name) => {
out.push(NmfRecordType::ExtensibleEncoding as u8);
encode_string(out, name.as_bytes())?;
}
Self::UnsizedEnvelope(payload) => {
// The unsized form is a streaming body. The .NET reference
// never produces this directly — it's set up by the
// negotiated mode. We emit the type byte; payload bytes
// are written by the caller because they may be chunked.
out.push(NmfRecordType::UnsizedEnvelope as u8);
out.extend_from_slice(payload);
}
Self::SizedEnvelope(payload) => {
out.push(NmfRecordType::SizedEnvelope as u8);
let payload_len = i32::try_from(payload.len())
.map_err(|_| NmfError::PayloadTooLarge { len: payload.len() })?;
encode_multibyte_int31(out, payload_len)?;
out.extend_from_slice(payload);
}
Self::End => out.push(NmfRecordType::End as u8),
Self::Fault(message) => {
out.push(NmfRecordType::Fault as u8);
encode_string(out, message.as_bytes())?;
}
Self::UpgradeRequest(name) => {
out.push(NmfRecordType::UpgradeRequest as u8);
encode_string(out, name.as_bytes())?;
}
Self::UpgradeResponse => out.push(NmfRecordType::UpgradeResponse as u8),
Self::PreambleAck => out.push(NmfRecordType::PreambleAck as u8),
Self::PreambleEnd => out.push(NmfRecordType::PreambleEnd as u8),
}
Ok(())
}
/// Encode to a fresh buffer. Convenience wrapper around
/// [`encode_into`].
pub fn encode(&self) -> Result<Vec<u8>, NmfError> {
let mut out = Vec::new();
self.encode_into(&mut out)?;
Ok(out)
}
/// Decode a single record. Returns `(record, bytes_consumed)`.
pub fn decode(input: &[u8]) -> Result<(Self, usize), NmfError> {
let kind_byte = *input.first().ok_or(NmfError::Truncated {
need: 1,
have: 0,
stage: "record-type",
})?;
let kind =
NmfRecordType::from_u8(kind_byte).ok_or(NmfError::UnknownRecordType(kind_byte))?;
let mut cursor = 1usize;
let record = match kind {
NmfRecordType::Version => {
let major = read_byte(input, &mut cursor, "version-major")?;
let minor = read_byte(input, &mut cursor, "version-minor")?;
Self::Version { major, minor }
}
NmfRecordType::Mode => {
let m = read_byte(input, &mut cursor, "mode-byte")?;
Self::Mode(NmfMode::from_u8(m).ok_or(NmfError::UnknownMode(m))?)
}
NmfRecordType::Via => Self::Via(decode_string(input, &mut cursor, "via")?),
NmfRecordType::KnownEncoding => {
let e = read_byte(input, &mut cursor, "encoding-byte")?;
Self::KnownEncoding(NmfEncoding::from_u8(e).ok_or(NmfError::UnknownEncoding(e))?)
}
NmfRecordType::ExtensibleEncoding => {
Self::ExtensibleEncoding(decode_string(input, &mut cursor, "extensible-encoding")?)
}
NmfRecordType::UnsizedEnvelope => {
// Unsized envelope is a streaming body; the codec returns
// the remaining bytes verbatim and the caller is
// responsible for splitting at the next `End` record.
let tail = input.get(cursor..).unwrap_or(&[]);
cursor += tail.len();
Self::UnsizedEnvelope(tail.to_vec())
}
NmfRecordType::SizedEnvelope => {
let len = decode_multibyte_int31(input, &mut cursor)?;
let len = usize::try_from(len).map_err(|_| NmfError::NegativeLength(len))?;
let payload = input.get(cursor..cursor + len).ok_or(NmfError::Truncated {
need: len,
have: input.len().saturating_sub(cursor),
stage: "sized-envelope-payload",
})?;
cursor += len;
Self::SizedEnvelope(payload.to_vec())
}
NmfRecordType::End => Self::End,
NmfRecordType::Fault => Self::Fault(decode_string(input, &mut cursor, "fault")?),
NmfRecordType::UpgradeRequest => {
Self::UpgradeRequest(decode_string(input, &mut cursor, "upgrade-request")?)
}
NmfRecordType::UpgradeResponse => Self::UpgradeResponse,
NmfRecordType::PreambleAck => Self::PreambleAck,
NmfRecordType::PreambleEnd => Self::PreambleEnd,
};
Ok((record, cursor))
}
}
/// Convenience: the canonical preamble sequence for an ASB `net.tcp`
/// connect (`Version 1.0` → `Duplex` → `Via $uri` →
/// `KnownEncoding(BinaryWithDictionary)` → `PreambleEnd`).
///
/// Mirrors the records WCF emits when `NetTcpBinding(SecurityMode.None)`
/// brings up a duplex channel — verified against
/// `analysis/proxy/mxasbclient-register-message.txt` capture preamble.
pub fn encode_preamble(via_uri: &str, out: &mut Vec<u8>) -> Result<(), NmfError> {
NmfRecord::Version { major: 1, minor: 0 }.encode_into(out)?;
NmfRecord::Mode(NmfMode::Duplex).encode_into(out)?;
NmfRecord::Via(via_uri.to_string()).encode_into(out)?;
NmfRecord::KnownEncoding(NmfEncoding::BinaryWithDictionary).encode_into(out)?;
NmfRecord::PreambleEnd.encode_into(out)?;
Ok(())
}
// ---- multibyte int31 -----------------------------------------------------
/// Encode a non-negative `i32` as `[MS-NMF]` §2.2.2.1 `Multibyte Int31`.
/// 7-bit little-endian groups; MSB signals continuation; max 5 bytes.
/// Negative values are rejected.
pub fn encode_multibyte_int31(out: &mut Vec<u8>, value: i32) -> Result<(), NmfError> {
if value < 0 {
return Err(NmfError::NegativeLength(value));
}
let mut v = value as u32;
loop {
let byte = (v & 0x7F) as u8;
v >>= 7;
if v == 0 {
out.push(byte);
return Ok(());
}
out.push(byte | 0x80);
}
}
/// Decode a `Multibyte Int31`. Reads at most 5 bytes; returns the parsed
/// value and advances `cursor`.
pub fn decode_multibyte_int31(input: &[u8], cursor: &mut usize) -> Result<i32, NmfError> {
let mut value: u32 = 0;
for shift in (0u32..).step_by(7).take(5) {
let byte = input.get(*cursor).copied().ok_or(NmfError::Truncated {
need: 1,
have: 0,
stage: "multibyte-int31",
})?;
*cursor += 1;
value |= ((byte & 0x7F) as u32).wrapping_shl(shift);
if byte & 0x80 == 0 {
return i32::try_from(value).map_err(|_| NmfError::IntOverflow);
}
}
Err(NmfError::IntOverflow)
}
// ---- string helpers ------------------------------------------------------
fn encode_string(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), NmfError> {
let len =
i32::try_from(bytes.len()).map_err(|_| NmfError::PayloadTooLarge { len: bytes.len() })?;
encode_multibyte_int31(out, len)?;
out.extend_from_slice(bytes);
Ok(())
}
fn decode_string(
input: &[u8],
cursor: &mut usize,
stage: &'static str,
) -> Result<String, NmfError> {
let len_i = decode_multibyte_int31(input, cursor)?;
let len = usize::try_from(len_i).map_err(|_| NmfError::NegativeLength(len_i))?;
let bytes = input
.get(*cursor..*cursor + len)
.ok_or(NmfError::Truncated {
need: len,
have: input.len().saturating_sub(*cursor),
stage,
})?;
*cursor += len;
String::from_utf8(bytes.to_vec()).map_err(|_| NmfError::InvalidUtf8 { stage })
}
fn read_byte(input: &[u8], cursor: &mut usize, stage: &'static str) -> Result<u8, NmfError> {
let byte = input.get(*cursor).copied().ok_or(NmfError::Truncated {
need: 1,
have: 0,
stage,
})?;
*cursor += 1;
Ok(byte)
}
// ---- error type ----------------------------------------------------------
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum NmfError {
#[error("truncated frame at {stage}: need {need} bytes, have {have}")]
Truncated {
need: usize,
have: usize,
stage: &'static str,
},
#[error("unknown NMF record type 0x{0:02x}")]
UnknownRecordType(u8),
#[error("unknown NMF mode 0x{0:02x}")]
UnknownMode(u8),
#[error("unknown NMF encoding 0x{0:02x}")]
UnknownEncoding(u8),
#[error("payload too large: {len} bytes (max {})", i32::MAX)]
PayloadTooLarge { len: usize },
#[error("multibyte int31 overflowed 31-bit unsigned range")]
IntOverflow,
#[error("negative length {0} in NMF frame")]
NegativeLength(i32),
#[error("invalid UTF-8 in NMF {stage} payload")]
InvalidUtf8 { stage: &'static str },
}
// `AuthError` is unrelated; this re-import exists only so consumers of the
// crate can use a single `use mxaccess_asb_nettcp::*;` statement and pull
// both auth + framing types in one go without a path collision.
#[allow(dead_code)]
const _AUTH_ERROR_IS_REACHABLE: fn(&AuthError) = |_| {};
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn round_trip(record: NmfRecord) {
let bytes = record.encode().unwrap();
let (decoded, consumed) = NmfRecord::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len(), "decode consumed != encoded len");
assert_eq!(decoded, record);
}
#[test]
fn version_round_trip() {
round_trip(NmfRecord::Version { major: 1, minor: 0 });
round_trip(NmfRecord::Version { major: 0, minor: 0 });
}
#[test]
fn mode_round_trip_all_modes() {
for m in [
NmfMode::Singleton,
NmfMode::Duplex,
NmfMode::Simplex,
NmfMode::SingletonSized,
] {
round_trip(NmfRecord::Mode(m));
}
}
#[test]
fn via_round_trip_with_ascii_uri() {
round_trip(NmfRecord::Via(
"net.tcp://localhost:5074/ASBService".to_string(),
));
}
#[test]
fn via_round_trip_with_unicode_uri() {
// `net.tcp://` URIs are ASCII in practice; this is a defensive
// round-trip to catch any UTF-8 corruption in the codec path.
round_trip(NmfRecord::Via("net.tcp://hôst.example/ásb".to_string()));
}
#[test]
fn known_encoding_round_trip() {
for e in [
NmfEncoding::Utf8SoapText,
NmfEncoding::Utf16SoapText,
NmfEncoding::Utf16LeSoapText,
NmfEncoding::Binary,
NmfEncoding::BinaryWithMtom,
NmfEncoding::Mtom,
NmfEncoding::BinaryWithDictionary,
] {
round_trip(NmfRecord::KnownEncoding(e));
}
}
#[test]
fn extensible_encoding_round_trip() {
round_trip(NmfRecord::ExtensibleEncoding(
"application/octet-stream".to_string(),
));
}
#[test]
fn sized_envelope_round_trip_small() {
round_trip(NmfRecord::SizedEnvelope(vec![]));
round_trip(NmfRecord::SizedEnvelope((0u8..=255).collect()));
}
#[test]
fn sized_envelope_round_trip_large_uses_multibyte_length() {
// 200-byte payload: length needs 2 multibyte-int31 bytes (200 =
// 0xC8, encoded as 0xC8 0x01).
let payload = vec![0xAB; 200];
let bytes = NmfRecord::SizedEnvelope(payload.clone()).encode().unwrap();
// type (1) + length-bytes (2) + payload (200)
assert_eq!(bytes.len(), 1 + 2 + 200);
assert_eq!(bytes[0], NmfRecordType::SizedEnvelope as u8);
assert_eq!(bytes[1], 0xC8);
assert_eq!(bytes[2], 0x01);
let (decoded, consumed) = NmfRecord::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert!(matches!(decoded, NmfRecord::SizedEnvelope(p) if p == payload));
}
#[test]
fn end_record_is_one_byte() {
let bytes = NmfRecord::End.encode().unwrap();
assert_eq!(bytes, vec![0x07]);
round_trip(NmfRecord::End);
}
#[test]
fn fault_record_round_trip() {
round_trip(NmfRecord::Fault("invalid request".to_string()));
}
#[test]
fn preamble_ack_and_end_round_trip() {
round_trip(NmfRecord::PreambleAck);
round_trip(NmfRecord::PreambleEnd);
round_trip(NmfRecord::UpgradeResponse);
}
#[test]
fn upgrade_request_round_trip() {
round_trip(NmfRecord::UpgradeRequest("application/ssl-tls".to_string()));
}
#[test]
fn unsized_envelope_round_trip_streams_payload_to_eof() {
// The unsized form returns whatever bytes follow the type byte —
// chunking is the caller's responsibility. Round-trip with an
// explicit payload to catch byte-loss in the codec.
let record = NmfRecord::UnsizedEnvelope(vec![0xDE, 0xAD, 0xBE, 0xEF]);
let bytes = record.encode().unwrap();
// Type byte + 4 payload bytes
assert_eq!(bytes.len(), 5);
let (decoded, _) = NmfRecord::decode(&bytes).unwrap();
assert_eq!(decoded, record);
}
#[test]
fn multibyte_int31_round_trip_known_vectors() {
// [MS-NMF] §2.2.2.1 examples + LEB128 reference vectors.
for (value, expected) in [
(0i32, vec![0x00u8]),
(1, vec![0x01]),
(127, vec![0x7F]),
(128, vec![0x80, 0x01]),
(16_383, vec![0xFF, 0x7F]),
(16_384, vec![0x80, 0x80, 0x01]),
(200, vec![0xC8, 0x01]),
(i32::MAX, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07]),
] {
let mut out = Vec::new();
encode_multibyte_int31(&mut out, value).unwrap();
assert_eq!(out, expected, "encoding {value}");
let mut cursor = 0;
let decoded = decode_multibyte_int31(&out, &mut cursor).unwrap();
assert_eq!(decoded, value);
assert_eq!(cursor, expected.len());
}
}
#[test]
fn multibyte_int31_rejects_negative() {
let mut out = Vec::new();
let err = encode_multibyte_int31(&mut out, -1).unwrap_err();
assert!(matches!(err, NmfError::NegativeLength(-1)));
}
#[test]
fn multibyte_int31_rejects_overflow() {
// 6 continuation bytes — beyond the 5-byte spec maximum.
let bytes = vec![0x80, 0x80, 0x80, 0x80, 0x80, 0x80];
let mut cursor = 0;
let err = decode_multibyte_int31(&bytes, &mut cursor).unwrap_err();
assert!(matches!(err, NmfError::IntOverflow));
}
#[test]
fn decode_rejects_unknown_record_type() {
let bytes = vec![0xFFu8];
let err = NmfRecord::decode(&bytes).unwrap_err();
assert!(matches!(err, NmfError::UnknownRecordType(0xFF)));
}
#[test]
fn decode_rejects_unknown_mode() {
let bytes = vec![NmfRecordType::Mode as u8, 0xEE];
let err = NmfRecord::decode(&bytes).unwrap_err();
assert!(matches!(err, NmfError::UnknownMode(0xEE)));
}
#[test]
fn decode_rejects_unknown_encoding() {
let bytes = vec![NmfRecordType::KnownEncoding as u8, 0x42];
let err = NmfRecord::decode(&bytes).unwrap_err();
assert!(matches!(err, NmfError::UnknownEncoding(0x42)));
}
#[test]
fn decode_rejects_truncated_sized_envelope() {
// Type + length(=10) but only 5 payload bytes.
let mut bytes = vec![NmfRecordType::SizedEnvelope as u8, 0x0A];
bytes.extend_from_slice(&[0xAA; 5]);
let err = NmfRecord::decode(&bytes).unwrap_err();
assert!(matches!(
err,
NmfError::Truncated {
stage: "sized-envelope-payload",
..
}
));
}
#[test]
fn preamble_emits_canonical_record_sequence() {
let mut out = Vec::new();
encode_preamble("net.tcp://localhost:5074/ASBService", &mut out).unwrap();
// Decode back and verify the sequence.
let mut cursor = 0;
let mut records = Vec::new();
while cursor < out.len() {
let (record, consumed) = NmfRecord::decode(&out[cursor..]).unwrap();
cursor += consumed;
records.push(record);
}
assert_eq!(cursor, out.len());
assert_eq!(records.len(), 5);
assert!(matches!(
records[0],
NmfRecord::Version { major: 1, minor: 0 }
));
assert!(matches!(records[1], NmfRecord::Mode(NmfMode::Duplex)));
match &records[2] {
NmfRecord::Via(uri) => assert_eq!(uri, "net.tcp://localhost:5074/ASBService"),
other => panic!("expected Via, got {other:?}"),
}
assert!(matches!(
records[3],
NmfRecord::KnownEncoding(NmfEncoding::BinaryWithDictionary)
));
assert!(matches!(records[4], NmfRecord::PreambleEnd));
}
#[test]
fn version_record_byte_layout() {
// [MS-NMF] §2.2.3.1: 0x00 major minor.
let bytes = NmfRecord::Version { major: 1, minor: 0 }.encode().unwrap();
assert_eq!(bytes, vec![0x00, 0x01, 0x00]);
}
#[test]
fn mode_record_byte_layout() {
// [MS-NMF] §2.2.3.2: 0x01 mode-byte. Duplex = 0x02.
let bytes = NmfRecord::Mode(NmfMode::Duplex).encode().unwrap();
assert_eq!(bytes, vec![0x01, 0x02]);
}
#[test]
fn known_encoding_record_byte_layout() {
// [MS-NMF] §2.2.3.4: 0x03 enc-byte. BinaryWithDictionary = 0x08.
let bytes = NmfRecord::KnownEncoding(NmfEncoding::BinaryWithDictionary)
.encode()
.unwrap();
assert_eq!(bytes, vec![0x03, 0x08]);
}
}
@@ -0,0 +1,44 @@
# Deterministic HMAC fixture
Pinned input/output triple for the `AsbSystemAuthenticator.Sign`
crypto path, captured from the .NET reference. Used by the Rust
parity test in `crates/mxaccess-asb-nettcp/tests/deterministic_hmac.rs`
to assert byte-equality of crypto_key derivation, canonical XML
emission, HMAC-SHA1, PBKDF2-SHA1 AES key derivation, and AES-CBC
encryption — independent of session randomness (DH private key,
remote public key, and AES IV are all pinned to deterministic values
so a single `cargo test` run can reproduce the .NET output).
## Capture procedure
```powershell
dotnet run --project src\MxAsbClient.Probe -c Release -- --dump-deterministic-hmac > capture.txt
```
The probe's `--dump-deterministic-hmac` flag (added 2026-05-05)
inlines the per-step decomposition of `Sign` (`AsbSystemAuthenticator
.cs:62-82`):
1. `shared = remote_pub^private_key mod prime` (.NET `BigInteger.ModPow`)
2. `crypto_key = shared || passphrase_utf8`
3. `xml = AuthenticateMe.ToXml()` with empty MAC + IV
4. `hmac = HMAC-SHA1(crypto_key, utf8(xml))`
5. `aes_key = PBKDF2-SHA1(base64(crypto_key), "ArchestrAService", 1000, 16)`
6. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, padding=PKCS7)`
Step 6 uses an all-zero IV to make the test reproducible — the real
wire path uses a random IV per call, but the Rust test bypasses the
random IV path by calling the AES primitive directly with the same
zero IV.
## File format
Plain-ASCII `key=value` lines, one per line. Hex values are
upper-case (matching .NET's `Convert.ToHexString`). The `xml_utf8_b64`
field encodes the canonical XML as base64 of the UTF-8 bytes.
## Files
- `authenticate-me.kv` — fixture for the `AuthenticateMe` shape with
the `[XmlType(Namespace="http://asb.contracts.data/20111111")]`
ConsumerAuthenticationData wrapper.
@@ -0,0 +1,21 @@
# deterministic-hmac fixture (.NET reference output)
prime_decimal=179769313486231590770839156793787453197860296048756011706444423684197180216158519368947833795864925541502180565485980503646440548199239100050792877003355816639229553136239076508735759914822574862575007425302077447712589550957937778424442426617334727629299387668709205606050270810842907692932019128194
generator=22
private_key_hex=0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F2000
remote_pub_hex=0D141B222930373E454C535A61686F767D848B9299A0A7AEB5BCC3CAD1D8DFE6EDF4FB020910171E252C333A41484F565D646B727980878E959CA3AAB1B8BFC6CDD4DBE2E9F0F7FE050C131A21282F363D444B525960676E757C838A91989FA6ADB4BBC2C9D0D7DEE5ECF3FA01080F161D242B323940474E555C636A71787F7F
passphrase=deterministic-hmac-fixture-passphrase-rust-vs-dotnet
connection_id=8cba964a-74c1-ef74-f6aa-761b3540191b
message_number=42
consumer_data_hex=070A0D101316191C1F2225282B2E3134373A3D404346494C4F5255585B5E6164676A6D707376797C7F8285888B8E9194979A9DA0A3A6A9ACAFB2B5B8BBBEC1C4C7CACDD0D3D6D9DCDFE2E5E8EBEEF1F4F7FAFD000306090C0F1215181B1E2124272A2D303336393C3F4245484B4E5154575A5D606366696C6F7275787B7E8184878A8D909396999C9FA2A5A8ABAEB1B4B7BABDC0C3C6C9CCCFD2D5D8DBDEE1E4E7EAEDF0F3F6F9FCFF0205080B0E1114171A1D202326292C2F3235383B3E4144474A4D505356595C5F6265686B6E7174
consumer_iv_hex=05101B26313C47525D68737E89949FAA
aes_iv_hex=00000000000000000000000000000000
shared_secret_hex=05F8563585C58EF5AF2A2DFFD4BC73FCD043FEFB470ED66EE07D5D9882DB27A478C58B6B857B300409064669C42C1C84F3457E6C0C4A00E578DF90DC817CB8BBDFE866F3EE9820E3BF8C772827C5E3BAE164553B4C65EC927865D7AA4F2AC5124F5F85B49A7C460F5BA06B4651A580D935BE1CFA577A9B2ED47980D200
shared_secret_len=125
crypto_key_hex=05F8563585C58EF5AF2A2DFFD4BC73FCD043FEFB470ED66EE07D5D9882DB27A478C58B6B857B300409064669C42C1C84F3457E6C0C4A00E578DF90DC817CB8BBDFE866F3EE9820E3BF8C772827C5E3BAE164553B4C65EC927865D7AA4F2AC5124F5F85B49A7C460F5BA06B4651A580D935BE1CFA577A9B2ED47980D20064657465726D696E69737469632D686D61632D666978747572652D706173737068726173652D727573742D76732D646F746E6574
crypto_key_len=177
xml_utf8_len=1136
xml_utf8_b64=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+DQo8QXV0aGVudGljYXRlTWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM6eHNkPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM9InVybjppbnZlbnN5cy5zY2hlbWFzIj4NCiAgPENvbm5lY3Rpb25WYWxpZGF0b3I+DQogICAgPENvbm5lY3Rpb25JZCB4bWxucz0iaHR0cDovL2FzYi5jb250cmFjdHMuZGF0YS8yMDExMTExMSI+OGNiYTk2NGEtNzRjMS1lZjc0LWY2YWEtNzYxYjM1NDAxOTFiPC9Db25uZWN0aW9uSWQ+DQogICAgPE1lc3NhZ2VOdW1iZXIgeG1sbnM9Imh0dHA6Ly9hc2IuY29udHJhY3RzLmRhdGEvMjAxMTExMTEiPjQyPC9NZXNzYWdlTnVtYmVyPg0KICAgIDxNZXNzYWdlQXV0aGVudGljYXRpb25Db2RlIHhtbG5zPSJodHRwOi8vYXNiLmNvbnRyYWN0cy5kYXRhLzIwMTExMTExIiAvPg0KICAgIDxTaWduYXR1cmVJbml0aWFsaXphdGlvblZlY3RvciB4bWxucz0iaHR0cDovL2FzYi5jb250cmFjdHMuZGF0YS8yMDExMTExMSIgLz4NCiAgPC9Db25uZWN0aW9uVmFsaWRhdG9yPg0KICA8Q29uc3VtZXJBdXRoZW50aWNhdGlvbkRhdGE+DQogICAgPERhdGEgeG1sbnM9Imh0dHA6Ly9hc2IuY29udHJhY3RzLmRhdGEvMjAxMTExMTEiPkJ3b05FQk1XR1J3ZklpVW9LeTR4TkRjNlBVQkRSa2xNVDFKVldGdGVZV1JuYW0xd2MzWjVmSCtDaFlpTGpwR1VsNXFkb0tPbXFheXZzclc0dTc3QnhNZkt6ZERUMXRuYzMrTGw2T3Z1OGZUMyt2MEFBd1lKREE4U0ZSZ2JIaUVrSnlvdE1ETTJPVHcvUWtWSVMwNVJWRmRhWFdCalptbHNiM0oxZUh0K2dZU0hpbzJRazVhWm5KK2lwYWlycnJHMHQ3cTl3TVBHeWN6UDB0WFkyOTdoNU9mcTdmRHo5dm44L3dJRkNBc09FUlFYR2gwZ0l5WXBMQzh5TlRnN1BrRkVSMHBOVUZOV1dWeGZZbVZvYTI1eGRBPT08L0RhdGE+DQogICAgPEluaXRpYWxpemF0aW9uVmVjdG9yIHhtbG5zPSJodHRwOi8vYXNiLmNvbnRyYWN0cy5kYXRhLzIwMTExMTExIj5CUkFiSmpFOFIxSmRhSE4raVpTZnFnPT08L0luaXRpYWxpemF0aW9uVmVjdG9yPg0KICA8L0NvbnN1bWVyQXV0aGVudGljYXRpb25EYXRhPg0KPC9BdXRoZW50aWNhdGVNZT4=
hmac_sha1_hex=4EDF6AF60E72C7026D2F5231F0E91FCEFC30E3D6
aes_key_hex=E5532AC4BFC5628B20B0ED307B2C88AC
encrypted_mac_hex=2E6A290397F688F2AE97B421184F44359C05FC59891BFA49BFD068C41EF9B42B
encrypted_mac_len=32
+4
View File
@@ -11,6 +11,10 @@ authors.workspace = true
[dependencies]
mxaccess-codec = { path = "../mxaccess-codec" }
mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" }
thiserror = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
rand = { workspace = true }
[features]
default = []
File diff suppressed because it is too large Load Diff
+687
View File
@@ -0,0 +1,687 @@
//! `IAsbCustomSerializableType` binary codecs.
//!
//! Ports the binary fast-path WCF uses for `Variant` /
//! `IAsbCustomSerializableType`-decorated structs. Each type writes a
//! `BinaryWriter`-style payload (LE primitives + `AsbBinary` UTF-16 LE
//! length-prefixed strings); the WCF `AsbDataCustomSerializer`
//! (`AsbContracts.cs:1507-1612`) then base64-encodes that payload and
//! wraps it inside an `<ASBIData>` element under the field's outer XML
//! tag.
//!
//! ## Scope
//!
//! Implements:
//! * [`ItemIdentity`] — used by RegisterItems / UnregisterItems / Read
//! / AddMonitoredItems / DeleteMonitoredItems request bodies.
//!
//! Stubbed for follow-up F25 iterations:
//! * `ItemStatus`, `ItemRegistration`, `WriteValue`, `RuntimeValue`
//! payloads, `ItemWriteComplete`, `MonitoredItemSettings`,
//! `MonitoredItem`. The pattern is identical — pure binary
//! round-trip — so the per-type cost is small once the
//! [`ItemIdentity`] reference establishes it.
use mxaccess_codec::{AsbStatus, AsbVariant, CodecError, RuntimeValue};
/// `ItemIdentity` per `AsbContracts.cs:533-633`. Wire layout:
///
/// | Offset | Size | Field | Notes |
/// |-------:|-----:|---------------|--------------------------------------|
/// | 0 | 2 | `Type` | u16 `ItemIdentityType` enum |
/// | 2 | 2 | `ReferenceType` | u16 `ItemReferenceType` enum |
/// | 4 | n | `Name` | `AsbBinary.WriteUnicodeString` |
/// | | m | `ContextName` | `AsbBinary.WriteUnicodeString` |
/// | | 8 | `Id` | u64 |
/// | | 1 | `IdSpecified` | bool (`BinaryWriter.Write(bool)`) |
///
/// `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)]
pub struct ItemIdentity {
pub kind: u16,
pub reference_type: u16,
pub name: Option<String>,
pub context_name: Option<String>,
pub id: u64,
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)]
pub enum ItemIdentityType {
Name = 0,
Id = 1,
NameAndId = 2,
}
/// `ItemReferenceType` enum (`AsbContracts.cs:1302-1308`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum ItemReferenceType {
None = 0,
Absolute = 1,
Hierarchical = 2,
Relative = 3,
}
impl ItemIdentity {
/// Convenience constructor for an absolute name reference. The
/// `MxAsbDataClient.CreateAbsoluteItem` path
/// (`MxAsbDataClient.cs:172-194`) sets `Type =
/// ItemIdentityType.Name`, `ReferenceType =
/// ItemReferenceType.Absolute`, and supplies the tag name. Most
/// register-time callers use this shape.
pub fn absolute_by_name(name: impl Into<String>) -> Self {
Self {
kind: ItemIdentityType::Name as u16,
reference_type: ItemReferenceType::Absolute as u16,
name: Some(name.into()),
// .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_specified: false,
}
}
pub fn encode_into(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.kind.to_le_bytes());
out.extend_from_slice(&self.reference_type.to_le_bytes());
write_unicode_string(out, self.name.as_deref());
write_unicode_string(out, self.context_name.as_deref());
out.extend_from_slice(&self.id.to_le_bytes());
out.push(if self.id_specified { 1 } else { 0 });
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let mut cursor = 0usize;
let kind = read_u16_le(input, &mut cursor)?;
let reference_type = read_u16_le(input, &mut cursor)?;
let name = read_unicode_string(input, &mut cursor)?;
let context_name = read_unicode_string(input, &mut cursor)?;
let id = read_u64_le(input, &mut cursor)?;
let id_specified = read_u8(input, &mut cursor)? != 0;
Ok((
Self {
kind,
reference_type,
name,
context_name,
id,
id_specified,
},
cursor,
))
}
}
/// `ItemStatus` per `AsbContracts.cs:639-722`. Wire layout (from the
/// `WriteToStream` method at `cs:682-688`):
///
/// | Field | Codec |
/// |----------------|-----------------------------|
/// | `Item` | [`ItemIdentity`] binary form |
/// | `Status` | [`AsbStatus`] binary form |
/// | `ErrorCode` | u16 |
/// | `ErrorCodeSpecified` | u8 (bool) |
///
/// Note the field order on the wire (`Item` then `Status`) is **NOT**
/// the `[DataMember(Order = …)]` declared order — `WriteToStream`
/// hand-picks Item-first, Status-second, then the trailing pair.
/// We mirror that exactly.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ItemStatus {
pub item: ItemIdentity,
pub status: AsbStatus,
pub error_code: u16,
pub error_code_specified: bool,
}
impl ItemStatus {
pub fn encode_into(&self, out: &mut Vec<u8>) {
self.item.encode_into(out);
self.status.encode_into(out);
out.extend_from_slice(&self.error_code.to_le_bytes());
out.push(if self.error_code_specified { 1 } else { 0 });
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let (item, item_consumed) = ItemIdentity::decode(input)?;
let mut cursor = item_consumed;
let status_tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 5,
actual: 0,
})?;
let (status, status_consumed) = AsbStatus::decode(status_tail)?;
cursor += status_consumed;
let error_code = read_u16_le(input, &mut cursor)?;
let error_code_specified = read_u8(input, &mut cursor)? != 0;
Ok((
Self {
item,
status,
error_code,
error_code_specified,
},
cursor,
))
}
}
/// Decode an array of `ItemStatus`es from the WCF custom-serializer
/// binary form (4-byte int32 count + each item's `WriteToStream`
/// output). Mirrors `ItemStatus.InitializeArrayFromStream`
/// (`cs:702-711`).
pub fn decode_item_status_array(input: &[u8]) -> Result<Vec<ItemStatus>, CodecError> {
let mut cursor = 0usize;
let count = read_i32_le(input, &mut cursor)?;
if count < 0 {
return Err(CodecError::Decode {
offset: 0,
reason: "negative item-status array count",
buffer_len: input.len(),
});
}
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
let tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (item, consumed) = ItemStatus::decode(tail)?;
cursor += consumed;
out.push(item);
}
Ok(out)
}
/// Encode an array of `ItemStatus`es. Mirrors `ItemStatus.WriteArrayToStream`
/// (`cs:713-721`) — 4-byte int32 count + each element's `WriteToStream`.
pub fn encode_item_status_array(items: &[ItemStatus]) -> Vec<u8> {
let mut out = Vec::new();
let count = i32::try_from(items.len()).unwrap_or(i32::MAX);
out.extend_from_slice(&count.to_le_bytes());
for item in items {
item.encode_into(&mut out);
}
out
}
/// `MonitoredItemValue` per `AsbContracts.cs:1032-1104`.
/// `IAsbCustomSerializableType` binary fast-path; payload order from
/// `WriteToStream` at `cs:1064-1068`:
///
/// 1. `Item` — [`ItemIdentity`] binary.
/// 2. `Value` — [`RuntimeValue`] binary (timestamp + variant + status).
/// 3. `UserData` — [`AsbVariant`] binary.
///
/// `MonitoredItemValue` arrives in `PublishResponse` as part of the
/// `Values` array — one entry per delivered sample.
#[derive(Debug, Clone, PartialEq)]
pub struct MonitoredItemValue {
pub item: ItemIdentity,
pub value: RuntimeValue,
pub user_data: AsbVariant,
}
impl MonitoredItemValue {
pub fn encode_into(&self, out: &mut Vec<u8>) {
self.item.encode_into(out);
self.value.encode_into(out);
self.user_data.encode_into(out);
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let (item, item_consumed) = ItemIdentity::decode(input)?;
let mut cursor = item_consumed;
let value_tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (value, value_consumed) = RuntimeValue::decode(value_tail)?;
cursor += value_consumed;
let user_data_tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (user_data, user_data_consumed) = AsbVariant::decode(user_data_tail)?;
cursor += user_data_consumed;
Ok((
Self {
item,
value,
user_data,
},
cursor,
))
}
}
/// Encode a `MonitoredItemValue[]` array per `WriteArrayToStream`
/// (`cs:1095-1103`) — 4-byte int32 count + per-element body.
pub fn encode_monitored_item_value_array(values: &[MonitoredItemValue]) -> Vec<u8> {
let mut out = Vec::new();
let count = i32::try_from(values.len()).unwrap_or(i32::MAX);
out.extend_from_slice(&count.to_le_bytes());
for v in values {
v.encode_into(&mut out);
}
out
}
/// Decode a `MonitoredItemValue[]` array. Mirrors
/// `MonitoredItemValue.InitializeArrayFromStream` (`cs:1084-1093`).
pub fn decode_monitored_item_value_array(
input: &[u8],
) -> Result<Vec<MonitoredItemValue>, CodecError> {
let mut cursor = 0usize;
let count = read_i32_le(input, &mut cursor)?;
if count < 0 {
return Err(CodecError::Decode {
offset: 0,
reason: "negative monitored-item-value array count",
buffer_len: input.len(),
});
}
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
let tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (v, consumed) = MonitoredItemValue::decode(tail)?;
cursor += consumed;
out.push(v);
}
Ok(out)
}
/// Encode an array of `IAsbCustomSerializableType` items per
/// `AsbDataCustomSerializer.WriteObjectContent` array branch
/// (`AsbContracts.cs:1583-1591` — calls `WriteArrayToStream` which
/// emits a 4-byte count followed by each element's `WriteToStream`).
pub fn encode_item_identity_array(items: &[ItemIdentity]) -> Vec<u8> {
let mut out = Vec::new();
let count = i32::try_from(items.len()).unwrap_or(i32::MAX);
out.extend_from_slice(&count.to_le_bytes());
for item in items {
item.encode_into(&mut out);
}
out
}
/// Decode an array of `ItemIdentity`s from the WCF custom-serializer
/// binary form (4-byte count + items). Mirrors
/// `ItemIdentity.InitializeArrayFromStream` (`cs:614-623`).
pub fn decode_item_identity_array(input: &[u8]) -> Result<Vec<ItemIdentity>, CodecError> {
let mut cursor = 0usize;
let count = read_i32_le(input, &mut cursor)?;
if count < 0 {
return Err(CodecError::Decode {
offset: 0,
reason: "negative item-identity array count",
buffer_len: input.len(),
});
}
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
let tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (item, consumed) = ItemIdentity::decode(tail)?;
cursor += consumed;
out.push(item);
}
Ok(out)
}
// ---- AsbBinary helpers ---------------------------------------------------
/// Mirror `AsbBinary.WriteUnicodeString` at `cs:1622-1633`. Null/empty
/// strings emit a 4-byte `0u32` length and no payload bytes.
fn write_unicode_string(out: &mut Vec<u8>, value: Option<&str>) {
let s = value.unwrap_or("");
if s.is_empty() {
out.extend_from_slice(&0u32.to_le_bytes());
return;
}
let mut utf16 = Vec::with_capacity(s.len() * 2);
for unit in s.encode_utf16() {
utf16.extend_from_slice(&unit.to_le_bytes());
}
let len = u32::try_from(utf16.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&utf16);
}
/// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0
/// → `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<Option<String>, CodecError> {
let len = read_u32_le(input, cursor)? as usize;
if len == 0 {
return Ok(Some(String::new()));
}
if len % 2 != 0 {
return Err(CodecError::Decode {
offset: *cursor,
reason: "unicode string length is odd",
buffer_len: input.len(),
});
}
let bytes = input
.get(*cursor..*cursor + len)
.ok_or(CodecError::ShortRead {
expected: len,
actual: input.len().saturating_sub(*cursor),
})?;
let mut units = Vec::with_capacity(len / 2);
for chunk in bytes.chunks_exact(2) {
let mut buf = [0u8; 2];
buf.copy_from_slice(chunk);
units.push(u16::from_le_bytes(buf));
}
let s = String::from_utf16(&units).map_err(|_| CodecError::Decode {
offset: *cursor,
reason: "invalid UTF-16 in unicode string",
buffer_len: input.len(),
})?;
*cursor += len;
Ok(Some(s))
}
fn read_u16_le(input: &[u8], cursor: &mut usize) -> Result<u16, CodecError> {
let bytes = read_array::<2>(input, cursor)?;
Ok(u16::from_le_bytes(bytes))
}
fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result<u32, CodecError> {
let bytes = read_array::<4>(input, cursor)?;
Ok(u32::from_le_bytes(bytes))
}
fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result<i32, CodecError> {
let bytes = read_array::<4>(input, cursor)?;
Ok(i32::from_le_bytes(bytes))
}
fn read_u64_le(input: &[u8], cursor: &mut usize) -> Result<u64, CodecError> {
let bytes = read_array::<8>(input, cursor)?;
Ok(u64::from_le_bytes(bytes))
}
fn read_u8(input: &[u8], cursor: &mut usize) -> Result<u8, CodecError> {
let byte = *input.get(*cursor).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
*cursor += 1;
Ok(byte)
}
fn read_array<const N: usize>(input: &[u8], cursor: &mut usize) -> Result<[u8; N], CodecError> {
let slice = input
.get(*cursor..*cursor + N)
.ok_or(CodecError::ShortRead {
expected: N,
actual: input.len().saturating_sub(*cursor),
})?;
let mut out = [0u8; N];
out.copy_from_slice(slice);
*cursor += N;
Ok(out)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn round_trip(item: ItemIdentity) {
let bytes = item.encode();
let (decoded, consumed) = ItemIdentity::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, item);
}
#[test]
fn item_identity_round_trip_default() {
round_trip(ItemIdentity::default());
}
#[test]
fn item_identity_round_trip_absolute_by_name() {
round_trip(ItemIdentity::absolute_by_name("TestChildObject.TestInt"));
}
#[test]
fn item_identity_round_trip_with_id() {
round_trip(ItemIdentity {
kind: ItemIdentityType::NameAndId as u16,
reference_type: ItemReferenceType::Absolute as u16,
name: Some("TestChildObject.TestInt".to_string()),
context_name: Some("TestObject".to_string()),
id: 0x1234_5678_9abc_def0,
id_specified: true,
});
}
#[test]
fn item_identity_round_trip_unicode_name() {
round_trip(ItemIdentity::absolute_by_name("TéstObj.Φοο"));
}
#[test]
fn item_identity_byte_layout_minimum_19_bytes() {
// Empty Name + empty ContextName + Id=0 + IdSpecified=false:
// 2 (kind) + 2 (refType) + 4 (name len=0) + 4 (ctx len=0)
// + 8 (id) + 1 (idSpecified) = 21 bytes.
let item = ItemIdentity::default();
let bytes = item.encode();
assert_eq!(bytes.len(), 21);
}
#[test]
fn unicode_string_round_trip_handles_null_empty_and_value() {
// 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(),
Some(String::new())
);
let mut buf = Vec::new();
write_unicode_string(&mut buf, Some(""));
let mut c = 0;
assert_eq!(
read_unicode_string(&buf, &mut c).unwrap(),
Some(String::new())
);
// ASCII
let mut buf = Vec::new();
write_unicode_string(&mut buf, Some("hi"));
let mut c = 0;
assert_eq!(
read_unicode_string(&buf, &mut c).unwrap(),
Some("hi".to_string())
);
}
#[test]
fn item_identity_array_round_trip() {
let items = vec![
ItemIdentity::absolute_by_name("Tag.A"),
ItemIdentity::absolute_by_name("Tag.B"),
ItemIdentity::absolute_by_name("Tag.C"),
];
let bytes = encode_item_identity_array(&items);
let decoded = decode_item_identity_array(&bytes).unwrap();
assert_eq!(decoded, items);
}
#[test]
fn item_identity_array_empty() {
let bytes = encode_item_identity_array(&[]);
// 4 bytes (count = 0)
assert_eq!(bytes.len(), 4);
assert_eq!(
decode_item_identity_array(&bytes).unwrap(),
Vec::<ItemIdentity>::new()
);
}
#[test]
fn item_status_round_trip() {
let s = ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.X"),
status: AsbStatus {
count: -1,
payload: vec![0xC0],
},
error_code: 0x1234,
error_code_specified: true,
};
let bytes = s.encode();
let (decoded, consumed) = ItemStatus::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, s);
}
#[test]
fn item_status_array_round_trip() {
let arr = vec![
ItemStatus::default(),
ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.A"),
status: AsbStatus {
count: 1,
payload: vec![0x01, 0x02],
},
error_code: 42,
error_code_specified: true,
},
];
let bytes = encode_item_status_array(&arr);
let decoded = decode_item_status_array(&bytes).unwrap();
assert_eq!(decoded, arr);
}
#[test]
fn monitored_item_value_round_trip() {
let mv = MonitoredItemValue {
item: ItemIdentity::absolute_by_name("Tag.X"),
value: RuntimeValue {
timestamp_binary: 0x0123_4567,
timestamp_specified: true,
value: AsbVariant::from_i32(100),
status: AsbStatus::default(),
},
user_data: AsbVariant::empty(),
};
let bytes = mv.encode();
let (decoded, consumed) = MonitoredItemValue::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, mv);
}
#[test]
fn monitored_item_value_array_round_trip() {
let arr = vec![
MonitoredItemValue {
item: ItemIdentity::absolute_by_name("Tag.A"),
value: RuntimeValue {
timestamp_binary: 1,
timestamp_specified: true,
value: AsbVariant::from_i32(1),
status: AsbStatus::default(),
},
user_data: AsbVariant::empty(),
},
MonitoredItemValue {
item: ItemIdentity::absolute_by_name("Tag.B"),
value: RuntimeValue {
timestamp_binary: 2,
timestamp_specified: false,
value: AsbVariant::from_string("hello"),
status: AsbStatus {
count: 1,
payload: vec![0xC0],
},
},
user_data: AsbVariant::from_bool(true),
},
];
let bytes = encode_monitored_item_value_array(&arr);
let decoded = decode_monitored_item_value_array(&bytes).unwrap();
assert_eq!(decoded, arr);
}
#[test]
fn item_identity_array_count_is_le_int32() {
let items = vec![ItemIdentity::default(); 7];
let bytes = encode_item_identity_array(&items);
// First 4 bytes = 7 little-endian.
assert_eq!(&bytes[0..4], &[0x07, 0x00, 0x00, 0x00]);
}
}
File diff suppressed because it is too large Load Diff
+41 -1
View File
@@ -1,5 +1,45 @@
//! `mxaccess-asb` — `IASBIDataV2` client.
//!
//! M0 stub. Real implementation lands in M5 — see `design/60-roadmap.md`.
//! M5 work-in-progress (F25). The first slice of F25 — SOAP-1.2-over-NBFX
//! envelope assembly + action constants for the full `IASBIDataV2`
//! contract — lives in [`envelope`]. Per-operation request/response
//! struct codecs and the network-bound `AsbClient` (TCP + NMF preamble +
//! sized-envelope read/write loop + auth handshake) land in subsequent
//! F25 iterations.
#![forbid(unsafe_code)]
pub mod client;
pub mod contracts;
pub mod envelope;
pub mod operations;
pub mod xml_canonical;
pub use client::{AsbClient, ClientError, PreambleMode};
pub use contracts::{
ItemIdentity, ItemIdentityType, ItemReferenceType, ItemStatus, MonitoredItemValue,
decode_item_identity_array, decode_item_status_array, decode_monitored_item_value_array,
encode_item_identity_array, encode_item_status_array, encode_monitored_item_value_array,
};
pub use envelope::{
ConnectionValidator, DecodedEnvelope, EnvelopeError, SoapEnvelope, actions, decode_envelope,
encode_envelope,
};
pub use operations::{
AddMonitoredItemsResponse, AuthenticationDataBytes, ConnectResponse,
CreateSubscriptionResponse, DeleteMonitoredItemsResponse, DeleteSubscriptionResponse,
MinimalMonitoredItem, MinimalWriteValue, OperationError, PublishResponse,
PublishWriteCompleteResponse, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse,
WriteResponse, build_add_monitored_items_request_body, build_authenticate_me_request_body,
build_connect_request_body, build_create_subscription_request_body,
build_delete_monitored_items_request_body, build_delete_subscription_request_body,
build_disconnect_request_body, build_keep_alive_request_body, build_publish_request_body,
build_publish_write_complete_request_body, build_read_request_body,
build_register_items_request_body, build_unregister_items_request_body,
build_write_request_body, collect_asbidata_payloads, decode_add_monitored_items_response,
decode_connect_response, decode_create_subscription_response,
decode_delete_monitored_items_response, decode_publish_response,
decode_publish_write_complete_response, decode_read_response, decode_register_items_response,
decode_unregister_items_response, decode_write_response,
};
File diff suppressed because it is too large Load Diff
@@ -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("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
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 `&lt;` and `&amp;`. 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 &lt; b &amp; c &gt; 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,
);
}
}
@@ -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
@@ -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 `<?xml ... ?>`):
`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
`<ConnectionValidator>`, 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 `<Name xsi:nil="true" xmlns="..." />`.
Empty string fields produce a self-closing `<ContextName xmlns="..." />`.
7. **`*Specified` pattern**: a public bool field named `XxxSpecified` =
`true` causes XmlSerializer to emit the corresponding `<Xxx>`
element. `IdSpecified = false` (default) → `<Id>` omitted.
`IdSpecified = true``<Id>` 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**: `<?xml version="1.0" encoding="utf-16"?>`
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).
@@ -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>
@@ -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">AAECAwQFBgcICQoLDA0ODw==</MessageAuthenticationCode>
<SignatureInitializationVector xmlns="http://asb.contracts.data/20111111">EBESExQVFhcYGRobHB0eHw==</SignatureInitializationVector>
</ConnectionValidator>
<ConsumerAuthenticationData>
<Data xmlns="http://asb.contracts.data/20111111">ZGV0ZXJtaW5pc3RpYy1jaXBoZXJ0ZXh0LWJ5dGVz</Data>
<InitializationVector xmlns="http://asb.contracts.data/20111111">MDEyMzQ1Njc4OWFiY2RlZg==</InitializationVector>
</ConsumerAuthenticationData>
</AuthenticateMe>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-16"?>
<Disconnect 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">AAECAwQFBgcICQoLDA0ODw==</MessageAuthenticationCode>
<SignatureInitializationVector xmlns="http://asb.contracts.data/20111111">EBESExQVFhcYGRobHB0eHw==</SignatureInitializationVector>
</ConnectionValidator>
<ConsumerAuthenticationData>
<Data xmlns="http://asb.contracts.data/20111111">ZGlzY29ubmVjdC1jaXBoZXJ0ZXh0</Data>
<InitializationVector xmlns="http://asb.contracts.data/20111111">MDEyMzQ1Njc4OWFiY2RlZg==</InitializationVector>
</ConsumerAuthenticationData>
</Disconnect>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-16"?>
<KeepAlive 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">AAECAwQFBgcICQoLDA0ODw==</MessageAuthenticationCode>
<SignatureInitializationVector xmlns="http://asb.contracts.data/20111111">EBESExQVFhcYGRobHB0eHw==</SignatureInitializationVector>
</ConnectionValidator>
</KeepAlive>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-16"?>
<RegisterItemsRequest 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">AAECAwQFBgcICQoLDA0ODw==</MessageAuthenticationCode>
<SignatureInitializationVector xmlns="http://asb.contracts.data/20111111">EBESExQVFhcYGRobHB0eHw==</SignatureInitializationVector>
</ConnectionValidator>
<Items>
<Type xmlns="urn:data.data.asb.iom:2">0</Type>
<ReferenceType xmlns="urn:data.data.asb.iom:2">1</ReferenceType>
<Name xmlns="urn:data.data.asb.iom:2">TestChildObject.TestInt</Name>
<ContextName xmlns="urn:data.data.asb.iom:2" />
</Items>
<RequireId>true</RequireId>
<RegisterOnly>false</RegisterOnly>
</RegisterItemsRequest>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-16"?>
<UnregisterItemsRequest 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">AAECAwQFBgcICQoLDA0ODw==</MessageAuthenticationCode>
<SignatureInitializationVector xmlns="http://asb.contracts.data/20111111">EBESExQVFhcYGRobHB0eHw==</SignatureInitializationVector>
</ConnectionValidator>
<Items>
<Type xmlns="urn:data.data.asb.iom:2">1</Type>
<ReferenceType xmlns="urn:data.data.asb.iom:2">1</ReferenceType>
<Name xsi:nil="true" xmlns="urn:data.data.asb.iom:2" />
<ContextName xsi:nil="true" xmlns="urn:data.data.asb.iom:2" />
<Id xmlns="urn:data.data.asb.iom:2">14627333968688430831</Id>
</Items>
</UnregisterItemsRequest>
@@ -0,0 +1,975 @@
//! ASB `Variant` + `AsbStatus` + `RuntimeValue` codec.
//!
//! Ports `src/MxAsbClient/AsbContracts.cs` (the `Variant`, `AsbStatus`, and
//! `RuntimeValue` `IAsbCustomSerializableType` blocks) plus the `DecodeVariant`
//! / `AsbVariantFactory` value-typed decode/encode in
//! `src/MxAsbClient/MxAsbDataClient.cs:713-825`. Spec-by-evidence: the wire
//! shape is documented in `docs/ASB-Variant-Wire-Format.md`.
//!
//! Layered for parity with the .NET reference:
//!
//! 1. [`AsbVariant`] is the raw 10-byte header + payload layout that round-
//! trips byte-for-byte against captured ASB messages. It carries a `u16`
//! type id, an `i32` "logical length" (set to `payload.len()` by the
//! factory), and a `u32` payload length followed by the payload bytes.
//! No interpretation; consumers can stash arbitrary unknown variants.
//! 2. [`DecodedVariant`] is the typed view. [`decode_variant`] consumes an
//! [`AsbVariant`] and produces a typed value for the proven matrix
//! (`Bool`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `Duration`,
//! plus their array forms). Unknown type IDs surface as
//! [`DecodedVariant::Unsupported`] carrying the raw payload — same
//! fallback as `MxAsbDataClient.DecodeVariant` at `cs:748` (return raw
//! bytes).
//! 3. The `from_*` factories mirror `AsbVariantFactory.From*` — they build
//! an `AsbVariant` whose `length` field is set to `payload.len()` (per
//! `cs:1316`). Wire bytes are produced by [`AsbVariant::encode`].
//!
//! [`AsbStatus`] and [`RuntimeValue`] round-trip exactly. The richer
//! status-element parsing (marker bit 7 = implicit zero; otherwise `u16`
//! follows) documented in `docs/ASB-Variant-Wire-Format.md:182-186` is
//! deferred to a follow-up — `AsbStatus.payload` is exposed as raw bytes
//! for now, mirroring the .NET reference, which keeps `Payload` as
//! `byte[]` and only `AsbPublishMapper.DecodeStatus` walks the records.
use std::string::FromUtf16Error;
use crate::error::CodecError;
/// ASB data type IDs from `AsbContracts.cs:1243-1293`. Stored as `u16` on
/// the wire. Variants outside the proven set (e.g. GUID, byte string,
/// localized text, enum/data-type/security/data-quality forms and their
/// arrays) are carried but not interpreted — matching the .NET reference,
/// which preserves them as raw bytes via the `_ => payload` fallback at
/// `MxAsbDataClient.cs:748`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum AsbDataType {
Byte = 0,
Char = 1,
Int16 = 2,
UInt16 = 3,
Int32 = 4,
UInt32 = 5,
Int64 = 6,
UInt64 = 7,
Float = 8,
Double = 9,
String = 10,
DateTime = 11,
Duration = 12,
Guid = 13,
ByteString = 14,
LocaleId = 15,
LocalizedText = 16,
Bool = 17,
SByte = 18,
ErrorStatus = 19,
Enum = 20,
DataType = 21,
SecurityClassification = 22,
DataQuality = 23,
ByteArray = 40,
CharArray = 41,
Int16Array = 42,
UInt16Array = 43,
Int32Array = 44,
UInt32Array = 45,
Int64Array = 46,
UInt64Array = 47,
FloatArray = 48,
DoubleArray = 49,
StringArray = 50,
DateTimeArray = 51,
DurationArray = 52,
GuidArray = 53,
ByteStringArray = 54,
LocaleIdArray = 55,
LocalizedTextArray = 56,
BoolArray = 57,
SByteArray = 58,
EnumArray = 60,
DataTypeArray = 61,
SecurityClassificationArray = 62,
DataQualityArray = 63,
Unknown = 65535,
}
impl AsbDataType {
pub fn as_u16(self) -> u16 {
self as u16
}
}
/// Raw ASB `Variant` wire layout (`AsbContracts.cs:1170-1241`).
///
/// `length` is the .NET `int` length set by the factory to `payload.len()`
/// at construction (`cs:1431-1438`). It is written separately from the
/// `u32` payload-length on the wire — both are emitted by the .NET writer
/// (`cs:1202-1211`). Decoders may legitimately observe `length != payload.len()`
/// for malformed or partial frames; this codec preserves both verbatim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsbVariant {
pub type_id: u16,
pub length: i32,
pub payload: Vec<u8>,
}
impl AsbVariant {
/// Build a variant with `length` set to `payload.len()` per
/// `AsbVariantFactory.Create` (`cs:1431-1438`).
pub fn new(type_id: AsbDataType, payload: Vec<u8>) -> Self {
let length = i32::try_from(payload.len()).unwrap_or(i32::MAX);
Self {
type_id: type_id.as_u16(),
length,
payload,
}
}
/// `AsbVariantFactory.Empty` — `TypeUnknown`, length 0, empty payload
/// (`cs:1312`).
pub fn empty() -> Self {
Self {
type_id: AsbDataType::Unknown.as_u16(),
length: 0,
payload: Vec::new(),
}
}
/// Wire size in bytes: 2 + 4 + 4 + payload.
pub fn wire_len(&self) -> usize {
10 + self.payload.len()
}
/// Encode `Variant.WriteToStream` (`cs:1202-1211`). Append-style so
/// callers can chain into a larger `BinaryWriter`-equivalent buffer
/// without intermediate allocations.
pub fn encode_into(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.type_id.to_le_bytes());
out.extend_from_slice(&self.length.to_le_bytes());
let payload_len = u32::try_from(self.payload.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&payload_len.to_le_bytes());
if !self.payload.is_empty() {
out.extend_from_slice(&self.payload);
}
}
/// Standalone encode: convenience wrapper around [`encode_into`].
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.wire_len());
self.encode_into(&mut out);
out
}
/// Decode `Variant.InitializeFromStream` (`cs:1213-1219`). Returns
/// `(variant, bytes_consumed)`. Empty payload → `payload: Vec::new()`,
/// matching .NET `Payload = []`.
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let mut cursor = 0usize;
let type_id = read_u16_le(input, &mut cursor)?;
let length = read_i32_le(input, &mut cursor)?;
let payload_length = read_u32_le(input, &mut cursor)? as usize;
let payload = read_bytes(input, &mut cursor, payload_length)?;
Ok((
Self {
type_id,
length,
payload: payload.to_vec(),
},
cursor,
))
}
}
/// Typed decode of an [`AsbVariant`].
///
/// Variant order follows the `AsbDataType` numerical sort. Unknown types
/// surface as [`Unsupported`](DecodedVariant::Unsupported) carrying both
/// the type ID and the raw payload, mirroring `DecodeVariant`'s `_ =>
/// payload` fallback at `MxAsbDataClient.cs:748`.
#[derive(Debug, Clone, PartialEq)]
pub enum DecodedVariant {
/// `null` from .NET when the payload is empty and the type does not
/// have an "empty literal" (e.g. empty `string`/`bool[]`/...).
/// Matches `_ => null` at `MxAsbDataClient.cs:728`.
Empty,
Bool(bool),
Int32(i32),
Float(f32),
Double(f64),
/// UTF-16LE-decoded contents.
String(String),
/// Windows FILETIME UTC value (`DateTime.ToFileTimeUtc()` —
/// 100-ns ticks since 1601-01-01 UTC).
DateTime(i64),
/// .NET `TimeSpan.Ticks` — 100-ns ticks.
Duration(i64),
BoolArray(Vec<bool>),
Int32Array(Vec<i32>),
FloatArray(Vec<f32>),
DoubleArray(Vec<f64>),
StringArray(Vec<String>),
DateTimeArray(Vec<i64>),
DurationArray(Vec<i64>),
/// Type IDs outside the proven matrix. Payload bytes are preserved
/// verbatim — the consumer can either decode them with a custom
/// helper or surface them upstream.
Unsupported {
type_id: u16,
payload: Vec<u8>,
},
}
/// Decode an [`AsbVariant`] into a typed value. Mirrors `MxAsbDataClient.DecodeVariant`
/// at `cs:713-750` exactly:
///
/// * Empty payload → empty literal for known string/array types
/// (`""` / `[]`), [`Empty`] otherwise.
/// * Non-empty payload that doesn't satisfy the minimum length for a
/// scalar (e.g. `TypeInt32` with 3 bytes) falls through to
/// [`Unsupported`] with the raw payload — matches .NET `when payload.Length >= 4`.
/// * Decode failures inside the typed branches surface as
/// [`CodecError::ShortRead`] / [`CodecError::Decode`] so the caller can
/// distinguish "wrong shape" from "unrecognized type".
///
/// [`Empty`]: DecodedVariant::Empty
/// [`Unsupported`]: DecodedVariant::Unsupported
pub fn decode_variant(variant: &AsbVariant) -> Result<DecodedVariant, CodecError> {
use AsbDataType::*;
let type_id = variant.type_id;
let payload = &variant.payload;
if payload.is_empty() {
return Ok(match type_id {
x if x == String.as_u16() => DecodedVariant::String(std::string::String::new()),
x if x == Int32Array.as_u16() => DecodedVariant::Int32Array(Vec::new()),
x if x == BoolArray.as_u16() => DecodedVariant::BoolArray(Vec::new()),
x if x == FloatArray.as_u16() => DecodedVariant::FloatArray(Vec::new()),
x if x == DoubleArray.as_u16() => DecodedVariant::DoubleArray(Vec::new()),
x if x == StringArray.as_u16() => DecodedVariant::StringArray(Vec::new()),
x if x == DateTimeArray.as_u16() => DecodedVariant::DateTimeArray(Vec::new()),
x if x == DurationArray.as_u16() => DecodedVariant::DurationArray(Vec::new()),
_ => DecodedVariant::Empty,
});
}
match type_id {
x if x == Bool.as_u16() && !payload.is_empty() => Ok(DecodedVariant::Bool(
payload.first().copied().unwrap_or(0) != 0,
)),
x if x == Int32.as_u16() && payload.len() >= 4 => {
Ok(DecodedVariant::Int32(i32::from_le_bytes(arr4(payload, 0)?)))
}
x if x == Float.as_u16() && payload.len() >= 4 => {
Ok(DecodedVariant::Float(f32::from_le_bytes(arr4(payload, 0)?)))
}
x if x == Double.as_u16() && payload.len() >= 8 => Ok(DecodedVariant::Double(
f64::from_le_bytes(arr8(payload, 0)?),
)),
x if x == String.as_u16() => Ok(DecodedVariant::String(decode_utf16le(payload)?)),
x if x == DateTime.as_u16() && payload.len() >= 8 => Ok(DecodedVariant::DateTime(
i64::from_le_bytes(arr8(payload, 0)?),
)),
x if x == Duration.as_u16() && payload.len() >= 8 => Ok(DecodedVariant::Duration(
i64::from_le_bytes(arr8(payload, 0)?),
)),
x if x == Int32Array.as_u16() => {
decode_int32_array(payload).map(DecodedVariant::Int32Array)
}
x if x == BoolArray.as_u16() => Ok(DecodedVariant::BoolArray(
payload.iter().map(|&b| b != 0).collect(),
)),
x if x == FloatArray.as_u16() => {
decode_float_array(payload).map(DecodedVariant::FloatArray)
}
x if x == DoubleArray.as_u16() => {
decode_double_array(payload).map(DecodedVariant::DoubleArray)
}
x if x == StringArray.as_u16() => {
decode_string_array(payload).map(DecodedVariant::StringArray)
}
x if x == DateTimeArray.as_u16() => {
decode_filetime_array(payload).map(DecodedVariant::DateTimeArray)
}
x if x == DurationArray.as_u16() => {
decode_filetime_array(payload).map(DecodedVariant::DurationArray)
}
_ => Ok(DecodedVariant::Unsupported {
type_id,
payload: payload.clone(),
}),
}
}
// ---- Factories (mirror `AsbVariantFactory.From*` at cs:1314-1429) --------
impl AsbVariant {
pub fn from_bool(value: bool) -> Self {
Self::new(AsbDataType::Bool, vec![if value { 1 } else { 0 }])
}
pub fn from_i32(value: i32) -> Self {
Self::new(AsbDataType::Int32, value.to_le_bytes().to_vec())
}
pub fn from_f32(value: f32) -> Self {
Self::new(AsbDataType::Float, value.to_le_bytes().to_vec())
}
pub fn from_f64(value: f64) -> Self {
Self::new(AsbDataType::Double, value.to_le_bytes().to_vec())
}
pub fn from_string(value: &str) -> Self {
Self::new(AsbDataType::String, encode_utf16le(value))
}
pub fn from_filetime(value: i64) -> Self {
Self::new(AsbDataType::DateTime, value.to_le_bytes().to_vec())
}
pub fn from_duration_ticks(value: i64) -> Self {
Self::new(AsbDataType::Duration, value.to_le_bytes().to_vec())
}
pub fn from_i32_array(values: &[i32]) -> Self {
let mut payload = Vec::with_capacity(values.len() * 4);
for v in values {
payload.extend_from_slice(&v.to_le_bytes());
}
Self::new(AsbDataType::Int32Array, payload)
}
pub fn from_bool_array(values: &[bool]) -> Self {
Self::new(
AsbDataType::BoolArray,
values.iter().map(|&b| if b { 1u8 } else { 0u8 }).collect(),
)
}
pub fn from_f32_array(values: &[f32]) -> Self {
let mut payload = Vec::with_capacity(values.len() * 4);
for v in values {
payload.extend_from_slice(&v.to_le_bytes());
}
Self::new(AsbDataType::FloatArray, payload)
}
pub fn from_f64_array(values: &[f64]) -> Self {
let mut payload = Vec::with_capacity(values.len() * 8);
for v in values {
payload.extend_from_slice(&v.to_le_bytes());
}
Self::new(AsbDataType::DoubleArray, payload)
}
/// String-array layout: per-string `i32` byte-length followed by
/// UTF-16LE bytes. `null` and `""` both emit a zero-length record
/// (`cs:1400`). The .NET decoder maps zero-length back to
/// `string.Empty` (`cs:798`).
pub fn from_string_array(values: &[&str]) -> Self {
let mut payload = Vec::new();
for value in values {
let bytes = encode_utf16le(value);
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
payload.extend_from_slice(&len.to_le_bytes());
payload.extend_from_slice(&bytes);
}
Self::new(AsbDataType::StringArray, payload)
}
pub fn from_filetime_array(values: &[i64]) -> Self {
let mut payload = Vec::with_capacity(values.len() * 8);
for v in values {
payload.extend_from_slice(&v.to_le_bytes());
}
Self::new(AsbDataType::DateTimeArray, payload)
}
pub fn from_duration_array(values: &[i64]) -> Self {
let mut payload = Vec::with_capacity(values.len() * 8);
for v in values {
payload.extend_from_slice(&v.to_le_bytes());
}
Self::new(AsbDataType::DurationArray, payload)
}
}
// ---- AsbStatus -----------------------------------------------------------
/// Wire layout: signed 1-byte `count`, 4-byte unsigned `payload_length`,
/// `payload_length` bytes of status elements (`cs:1109-1167`). The richer
/// status-element walk (marker-byte bit 7 = implicit zero, etc., see
/// `docs/ASB-Variant-Wire-Format.md:180-205`) is deliberately not done
/// here; the codec round-trips the payload bytes verbatim and exposes a
/// raw accessor so consumers (or a higher-level `StatusElement` parser
/// added later) can walk them.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AsbStatus {
pub count: i8,
pub payload: Vec<u8>,
}
impl AsbStatus {
pub fn wire_len(&self) -> usize {
1 + 4 + self.payload.len()
}
pub fn encode_into(&self, out: &mut Vec<u8>) {
out.push(self.count as u8);
let len = u32::try_from(self.payload.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_le_bytes());
if !self.payload.is_empty() {
out.extend_from_slice(&self.payload);
}
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.wire_len());
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let mut cursor = 0usize;
let count_byte = *input.first().ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let count = count_byte as i8;
cursor += 1;
let payload_length = read_u32_le(input, &mut cursor)? as usize;
let payload = read_bytes(input, &mut cursor, payload_length)?;
Ok((
Self {
count,
payload: payload.to_vec(),
},
cursor,
))
}
}
// ---- RuntimeValue --------------------------------------------------------
/// Wraps an [`AsbVariant`] with a `DateTime.ToBinary()` timestamp + status
/// per `RuntimeValue` at `cs:741-791`. The 8-byte timestamp is the .NET
/// `DateTime.ToBinary()` packed value (62-bit ticks + 2-bit kind); we
/// preserve it as `i64` rather than splitting because consumers vary in
/// whether they care about the kind bits, and the read path on .NET uses
/// `DateTime.FromBinary` which round-trips the exact value.
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeValue {
pub timestamp_binary: i64,
pub timestamp_specified: bool,
pub value: AsbVariant,
pub status: AsbStatus,
}
impl RuntimeValue {
pub fn wire_len(&self) -> usize {
8 + 1 + self.value.wire_len() + self.status.wire_len()
}
pub fn encode_into(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.timestamp_binary.to_le_bytes());
out.push(if self.timestamp_specified { 1 } else { 0 });
self.value.encode_into(out);
self.status.encode_into(out);
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.wire_len());
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let mut cursor = 0usize;
let timestamp_binary = read_i64_le(input, &mut cursor)?;
let flag_byte = input.get(cursor).copied().ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let timestamp_specified = flag_byte != 0;
cursor += 1;
let value_tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 10,
actual: 0,
})?;
let (value, value_consumed) = AsbVariant::decode(value_tail)?;
cursor += value_consumed;
let status_tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 5,
actual: 0,
})?;
let (status, status_consumed) = AsbStatus::decode(status_tail)?;
cursor += status_consumed;
Ok((
Self {
timestamp_binary,
timestamp_specified,
value,
status,
},
cursor,
))
}
}
// ---- helpers --------------------------------------------------------------
fn read_array<const N: usize>(input: &[u8], cursor: &mut usize) -> Result<[u8; N], CodecError> {
let slice = read_bytes(input, cursor, N)?;
let mut out = [0u8; N];
out.copy_from_slice(slice);
Ok(out)
}
fn read_u16_le(input: &[u8], cursor: &mut usize) -> Result<u16, CodecError> {
Ok(u16::from_le_bytes(read_array::<2>(input, cursor)?))
}
fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result<u32, CodecError> {
Ok(u32::from_le_bytes(read_array::<4>(input, cursor)?))
}
fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result<i32, CodecError> {
Ok(i32::from_le_bytes(read_array::<4>(input, cursor)?))
}
fn read_i64_le(input: &[u8], cursor: &mut usize) -> Result<i64, CodecError> {
Ok(i64::from_le_bytes(read_array::<8>(input, cursor)?))
}
fn read_bytes<'a>(
input: &'a [u8],
cursor: &mut usize,
needed: usize,
) -> Result<&'a [u8], CodecError> {
let end = cursor.checked_add(needed).ok_or(CodecError::ShortRead {
expected: needed,
actual: input.len().saturating_sub(*cursor),
})?;
if end > input.len() {
return Err(CodecError::ShortRead {
expected: needed,
actual: input.len().saturating_sub(*cursor),
});
}
let slice = input.get(*cursor..end).ok_or(CodecError::ShortRead {
expected: needed,
actual: input.len().saturating_sub(*cursor),
})?;
*cursor = end;
Ok(slice)
}
fn arr4(payload: &[u8], offset: usize) -> Result<[u8; 4], CodecError> {
let slice = payload
.get(offset..offset + 4)
.ok_or(CodecError::ShortRead {
expected: 4,
actual: payload.len().saturating_sub(offset),
})?;
let mut out = [0u8; 4];
out.copy_from_slice(slice);
Ok(out)
}
fn arr8(payload: &[u8], offset: usize) -> Result<[u8; 8], CodecError> {
let slice = payload
.get(offset..offset + 8)
.ok_or(CodecError::ShortRead {
expected: 8,
actual: payload.len().saturating_sub(offset),
})?;
let mut out = [0u8; 8];
out.copy_from_slice(slice);
Ok(out)
}
fn decode_int32_array(payload: &[u8]) -> Result<Vec<i32>, CodecError> {
let count = payload.len() / 4;
let mut out = Vec::with_capacity(count);
for i in 0..count {
out.push(i32::from_le_bytes(arr4(payload, i * 4)?));
}
Ok(out)
}
fn decode_float_array(payload: &[u8]) -> Result<Vec<f32>, CodecError> {
let count = payload.len() / 4;
let mut out = Vec::with_capacity(count);
for i in 0..count {
out.push(f32::from_le_bytes(arr4(payload, i * 4)?));
}
Ok(out)
}
fn decode_double_array(payload: &[u8]) -> Result<Vec<f64>, CodecError> {
let count = payload.len() / 8;
let mut out = Vec::with_capacity(count);
for i in 0..count {
out.push(f64::from_le_bytes(arr8(payload, i * 8)?));
}
Ok(out)
}
fn decode_filetime_array(payload: &[u8]) -> Result<Vec<i64>, CodecError> {
let count = payload.len() / 8;
let mut out = Vec::with_capacity(count);
for i in 0..count {
out.push(i64::from_le_bytes(arr8(payload, i * 8)?));
}
Ok(out)
}
/// String-array decode: walks `i32` length + UTF-16LE bytes records until
/// the payload is exhausted or a malformed length is encountered.
/// `MxAsbDataClient.DecodeStringArray` (`cs:785-803`) stops on negative
/// length or out-of-range; partial values decoded before that point are
/// kept. We mirror that exactly.
fn decode_string_array(payload: &[u8]) -> Result<Vec<String>, CodecError> {
let mut values = Vec::new();
let mut offset = 0usize;
while offset + 4 <= payload.len() {
let len_bytes = payload
.get(offset..offset + 4)
.ok_or(CodecError::ShortRead {
expected: 4,
actual: payload.len().saturating_sub(offset),
})?;
let mut buf = [0u8; 4];
buf.copy_from_slice(len_bytes);
let byte_length = i32::from_le_bytes(buf);
offset += 4;
if byte_length < 0 || (byte_length as usize) > payload.len().saturating_sub(offset) {
break;
}
let byte_length = byte_length as usize;
if byte_length == 0 {
values.push(String::new());
continue;
}
let str_bytes = payload
.get(offset..offset + byte_length)
.ok_or(CodecError::ShortRead {
expected: byte_length,
actual: payload.len().saturating_sub(offset),
})?;
values.push(decode_utf16le(str_bytes)?);
offset += byte_length;
}
Ok(values)
}
fn encode_utf16le(value: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(value.len() * 2);
for code_unit in value.encode_utf16() {
out.extend_from_slice(&code_unit.to_le_bytes());
}
out
}
fn decode_utf16le(bytes: &[u8]) -> Result<String, CodecError> {
if bytes.len() % 2 != 0 {
return Err(CodecError::Decode {
offset: bytes.len(),
reason: "UTF-16LE payload has odd byte length",
buffer_len: bytes.len(),
});
}
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|chunk| {
let mut buf = [0u8; 2];
buf.copy_from_slice(chunk);
u16::from_le_bytes(buf)
})
.collect();
let buf_len = bytes.len();
String::from_utf16(&units).map_err(|err: FromUtf16Error| CodecError::Decode {
offset: 0,
reason: utf16_error_reason(&err),
buffer_len: buf_len,
})
}
const fn utf16_error_reason(_: &FromUtf16Error) -> &'static str {
// FromUtf16Error doesn't carry a position; fixed string preserves the
// 'static-reason contract used by CodecError variants.
"UTF-16LE payload contains an unpaired surrogate"
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn round_trip_variant(variant: AsbVariant) {
let bytes = variant.encode();
let (decoded, consumed) = AsbVariant::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len(), "decode consumed != encoded len");
assert_eq!(decoded, variant, "wire round-trip diverged");
}
#[test]
fn variant_empty_round_trip() {
round_trip_variant(AsbVariant::empty());
}
#[test]
fn variant_bool_round_trip() {
round_trip_variant(AsbVariant::from_bool(true));
round_trip_variant(AsbVariant::from_bool(false));
}
#[test]
fn variant_i32_round_trip() {
round_trip_variant(AsbVariant::from_i32(0));
round_trip_variant(AsbVariant::from_i32(123));
round_trip_variant(AsbVariant::from_i32(i32::MIN));
round_trip_variant(AsbVariant::from_i32(i32::MAX));
}
#[test]
fn variant_floats_round_trip() {
round_trip_variant(AsbVariant::from_f32(1.5));
round_trip_variant(AsbVariant::from_f64(-std::f64::consts::E));
}
#[test]
fn variant_string_round_trip() {
round_trip_variant(AsbVariant::from_string(""));
round_trip_variant(AsbVariant::from_string("hello world"));
round_trip_variant(AsbVariant::from_string("éàü 漢字"));
}
#[test]
fn variant_datetime_round_trip() {
round_trip_variant(AsbVariant::from_filetime(0));
round_trip_variant(AsbVariant::from_filetime(132_845_000_000_000_000));
}
#[test]
fn variant_duration_round_trip() {
round_trip_variant(AsbVariant::from_duration_ticks(0));
round_trip_variant(AsbVariant::from_duration_ticks(1_234_567_890));
}
#[test]
fn variant_int32_array_round_trip() {
round_trip_variant(AsbVariant::from_i32_array(&[]));
round_trip_variant(AsbVariant::from_i32_array(&[1, 2, 3, -4, i32::MAX]));
}
#[test]
fn variant_bool_array_round_trip() {
round_trip_variant(AsbVariant::from_bool_array(&[]));
round_trip_variant(AsbVariant::from_bool_array(&[true, false, true, true]));
}
#[test]
fn variant_float_array_round_trip() {
round_trip_variant(AsbVariant::from_f32_array(&[1.0, -2.0, 3.5]));
round_trip_variant(AsbVariant::from_f64_array(&[std::f64::consts::PI, -0.0]));
}
#[test]
fn variant_string_array_round_trip() {
round_trip_variant(AsbVariant::from_string_array(&[]));
round_trip_variant(AsbVariant::from_string_array(&["alpha", "", "γαμμα"]));
}
#[test]
fn variant_datetime_and_duration_arrays_round_trip() {
round_trip_variant(AsbVariant::from_filetime_array(&[
0,
132_845_000_000_000_000,
i64::MAX,
]));
round_trip_variant(AsbVariant::from_duration_array(&[-1, i64::MIN, 42]));
}
#[test]
fn decode_variant_handles_empty_arrays_to_empty_typed_values() {
let v = AsbVariant {
type_id: AsbDataType::Int32Array.as_u16(),
length: 0,
payload: Vec::new(),
};
assert_eq!(
decode_variant(&v).unwrap(),
DecodedVariant::Int32Array(Vec::new())
);
let v = AsbVariant {
type_id: AsbDataType::String.as_u16(),
length: 0,
payload: Vec::new(),
};
assert_eq!(
decode_variant(&v).unwrap(),
DecodedVariant::String(String::new())
);
}
#[test]
fn decode_variant_returns_empty_for_unknown_type_with_empty_payload() {
let v = AsbVariant {
type_id: AsbDataType::Bool.as_u16(),
length: 0,
payload: Vec::new(),
};
assert_eq!(decode_variant(&v).unwrap(), DecodedVariant::Empty);
}
#[test]
fn decode_variant_int32() {
let v = AsbVariant::from_i32(0x1234_5678);
assert_eq!(
decode_variant(&v).unwrap(),
DecodedVariant::Int32(0x1234_5678)
);
}
#[test]
fn decode_variant_string() {
let v = AsbVariant::from_string("hello");
assert_eq!(
decode_variant(&v).unwrap(),
DecodedVariant::String("hello".to_string())
);
}
#[test]
fn decode_variant_string_array_with_empty_entries() {
let v = AsbVariant::from_string_array(&["a", "", "bc"]);
let decoded = decode_variant(&v).unwrap();
match decoded {
DecodedVariant::StringArray(values) => {
assert_eq!(
values,
vec!["a".to_string(), String::new(), "bc".to_string()]
);
}
other => panic!("expected StringArray, got {other:?}"),
}
}
#[test]
fn decode_variant_unsupported_type_returns_raw_bytes() {
let v = AsbVariant {
type_id: AsbDataType::Guid.as_u16(),
length: 16,
payload: vec![0xAB; 16],
};
match decode_variant(&v).unwrap() {
DecodedVariant::Unsupported { type_id, payload } => {
assert_eq!(type_id, AsbDataType::Guid.as_u16());
assert_eq!(payload, vec![0xAB; 16]);
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
#[test]
fn decode_variant_int32_too_short_falls_through_to_unsupported() {
// payload < 4 bytes for TypeInt32 — match-arm guard fails and
// .NET hits the `_ => payload` fallback (cs:748). We mirror that.
let v = AsbVariant {
type_id: AsbDataType::Int32.as_u16(),
length: 3,
payload: vec![1, 2, 3],
};
match decode_variant(&v).unwrap() {
DecodedVariant::Unsupported { type_id, payload } => {
assert_eq!(type_id, AsbDataType::Int32.as_u16());
assert_eq!(payload, vec![1, 2, 3]);
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
#[test]
fn variant_decode_rejects_truncated_header() {
// Cut off before the payload-length field finishes.
let bytes = vec![0x04, 0x00, 1, 0, 0, 0, 0xFF];
let err = AsbVariant::decode(&bytes).unwrap_err();
assert!(matches!(err, CodecError::ShortRead { .. }));
}
#[test]
fn asb_status_round_trip() {
let status = AsbStatus {
count: -3,
payload: vec![0x01, 0x02, 0x03],
};
let bytes = status.encode();
let (decoded, consumed) = AsbStatus::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, status);
}
#[test]
fn asb_status_round_trip_empty() {
let status = AsbStatus::default();
let bytes = status.encode();
let (decoded, consumed) = AsbStatus::decode(&bytes).unwrap();
assert_eq!(consumed, 5);
assert_eq!(decoded, status);
}
#[test]
fn runtime_value_round_trip() {
let rv = RuntimeValue {
timestamp_binary: 0x0123_4567_89AB_CDEF,
timestamp_specified: true,
value: AsbVariant::from_i32(42),
status: AsbStatus {
count: 1,
payload: vec![0xC0],
},
};
let bytes = rv.encode();
let (decoded, consumed) = RuntimeValue::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, rv);
}
#[test]
fn runtime_value_round_trip_empty_variant() {
let rv = RuntimeValue {
timestamp_binary: 0,
timestamp_specified: false,
value: AsbVariant::empty(),
status: AsbStatus::default(),
};
let bytes = rv.encode();
let (decoded, consumed) = RuntimeValue::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, rv);
}
#[test]
fn variant_wire_layout_is_2_4_4_payload() {
// .NET reference: WriteToStream writes Type (u16), Length (i32),
// payloadLength (u32), payload bytes. Verify byte positions.
let v = AsbVariant::from_i32(0xAABB_CCDD_u32 as i32);
let bytes = v.encode();
// type_id 0x0004 little-endian
assert_eq!(&bytes[0..2], &[0x04, 0x00]);
// length = 4
assert_eq!(&bytes[2..6], &[0x04, 0x00, 0x00, 0x00]);
// payload length = 4
assert_eq!(&bytes[6..10], &[0x04, 0x00, 0x00, 0x00]);
// payload = 0xAABB_CCDD little-endian
assert_eq!(&bytes[10..14], &[0xDD, 0xCC, 0xBB, 0xAA]);
}
}
+7 -11
View File
@@ -15,14 +15,15 @@
//! `NmxTransferEnvelopeTemplate` (round-trip preserver).
//!
//! Remaining (wave 2): `NmxSecuredWrite2Message` (`0x38`),
//! `ObservedWriteBodyTemplate`. ASB Variant + AsbStatus + RuntimeValue land
//! in M5.
//! `ObservedWriteBodyTemplate`. ASB Variant + AsbStatus + RuntimeValue
//! landed in the F24 sub-stream of M5 — see [`asb_variant`].
//!
//! Every wire shape here is grounded in `src/MxNativeCodec/*.cs` (the .NET
//! reference) and `captures/0NN-frida-*` (Frida ground truth).
#![forbid(unsafe_code)]
pub mod asb_variant;
pub mod envelope;
pub mod envelope_template;
pub mod error;
@@ -68,16 +69,11 @@ pub struct NmxWriteMessage;
#[derive(Debug, Clone)]
pub struct NmxSecuredWrite2Message;
// ---- ASB types (M5 follow-up) --------------------------------------------
// ---- ASB types (M5, F24) -------------------------------------------------
#[derive(Debug, Clone)]
pub struct AsbVariant;
#[derive(Debug, Clone, Copy, Default)]
pub struct AsbStatus;
#[derive(Debug, Clone)]
pub struct RuntimeValue;
pub use asb_variant::{
AsbDataType, AsbStatus, AsbVariant, DecodedVariant, RuntimeValue, decode_variant,
};
// ---- Convenience prelude -------------------------------------------------
+2
View File
@@ -14,6 +14,8 @@ mxaccess-callback = { path = "../mxaccess-callback" }
mxaccess-galaxy = { path = "../mxaccess-galaxy" }
mxaccess-nmx = { path = "../mxaccess-nmx" }
mxaccess-rpc = { path = "../mxaccess-rpc" }
mxaccess-asb = { path = "../mxaccess-asb" }
mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
@@ -0,0 +1,128 @@
//! `asb-preamble-probe` — diagnostic for the F20 NMF preamble vs what
//! AVEVA's NetTcpPortSharing (SMSvcHost) actually accepts.
//!
//! Connects to the configured ASB endpoint, sends the canonical
//! preamble that F25's `AsbClient::send_preamble` would send, then
//! reads up to 256 bytes from the peer and prints both sides as
//! hex. Useful for diffing against a Wireshark / pktmon capture of
//! the .NET reference probe.
use std::time::Duration;
use mxaccess_asb::{SoapEnvelope, actions, build_connect_request_body, encode_envelope};
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
use mxaccess_asb_nettcp::nmf::{self, NmfRecord};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let host = std::env::var("MX_ASB_HOST")?;
let via = std::env::var("MX_ASB_VIA")?;
let addr = parse_host_port(&host, 808)?;
eprintln!("connecting {addr} (via={via})");
let mut stream = TcpStream::connect(addr).await?;
let mut buf = Vec::new();
nmf::encode_preamble(&via, &mut buf)?;
eprintln!("sending {} preamble bytes:", buf.len());
print_hex("OUT", &buf);
stream.write_all(&buf).await?;
stream.flush().await?;
// Read the PreambleAck (single 0x0b byte) before pushing further.
let mut ack = [0u8; 1];
tokio::time::timeout(Duration::from_secs(5), stream.read_exact(&mut ack)).await??;
eprintln!("preamble-ack byte: 0x{:02x}", ack[0]);
if ack[0] != 0x0b {
eprintln!("expected PreambleAck (0x0b); aborting");
return Ok(());
}
// Build a synthetic ConnectRequest with a placeholder public key
// (32 bytes, not a real DH public key — SMSvcHost dispatches by
// the wire URL but WCF inside it will eventually decode the
// envelope and may reject the body. That rejection is what we
// want to observe.)
let connection_id = [0xAAu8; 16];
let public_key = vec![0xBBu8; 32];
let body = build_connect_request_body(connection_id, &public_key);
let envelope = SoapEnvelope::new(actions::CONNECT)
.with_to(&via)
.with_body_tokens(body);
let mut dynamic = DynamicDictionary::new();
let payload = encode_envelope(&envelope, &mut dynamic)?;
eprintln!("envelope NBFX bytes: {}", payload.len());
print_hex("ENV", &payload);
let mut framed = Vec::new();
NmfRecord::SizedEnvelope(payload).encode_into(&mut framed)?;
eprintln!(
"framed SizedEnvelope: {} bytes (1 type + varint len + body)",
framed.len()
);
print_hex("OUT", &framed);
stream.write_all(&framed).await?;
stream.flush().await?;
// Read up to 4096 bytes back — Fault would be small, ConnectResponse
// would be larger (~200-400 bytes typically).
let mut reply = vec![0u8; 4096];
let read = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut reply)).await;
match read {
Ok(Ok(0)) => eprintln!("peer closed cleanly (0 bytes)"),
Ok(Ok(n)) => {
eprintln!("got {n} bytes back:");
if let Some(slice) = reply.get(..n) {
print_hex("IN ", slice);
}
// First byte tells us the record type.
match reply.first().copied().unwrap_or(0) {
0x06 => eprintln!("response = SizedEnvelope (good — WCF accepted the request)"),
0x07 => eprintln!("response = End (peer drained cleanly)"),
0x08 => eprintln!("response = Fault (peer rejected; check the message text)"),
other => eprintln!("response = unknown record type 0x{other:02x}"),
}
}
Ok(Err(e)) => eprintln!("read error: {e}"),
Err(_) => eprintln!("read timed out after 10s"),
}
Ok(())
}
fn print_hex(tag: &str, bytes: &[u8]) {
for chunk in bytes.chunks(16) {
let hex: Vec<String> = chunk.iter().map(|b| format!("{b:02x}")).collect();
let ascii: String = chunk
.iter()
.map(|b| {
if b.is_ascii_graphic() || *b == b' ' {
*b as char
} else {
'.'
}
})
.collect();
eprintln!("{tag} {:<48} {}", hex.join(" "), ascii);
}
}
fn parse_host_port(
s: &str,
default_port: u16,
) -> Result<std::net::SocketAddr, Box<dyn std::error::Error>> {
if let Ok(addr) = s.parse() {
return Ok(addr);
}
let with_port = if s.contains(':') {
s.to_string()
} else {
format!("{s}:{default_port}")
};
Ok(
std::net::ToSocketAddrs::to_socket_addrs(&with_port.as_str())?
.next()
.ok_or("no addrs resolved")?,
)
}
+154
View File
@@ -0,0 +1,154 @@
//! `asb-relay` — TCP middleman that captures both sides of an ASB
//! exchange.
//!
//! Listens on `MX_RELAY_LISTEN` (default `127.0.0.1:8088`) and forwards
//! every connection to `MX_RELAY_UPSTREAM` (default `127.0.0.1:808`,
//! AVEVA's `NetTcpPortSharing` SMSvcHost listener). All bytes both
//! directions are hex-dumped to stderr with direction + offset
//! prefixes, so you can `> capture.log 2>&1` and diff client-vs-server
//! bytes byte-for-byte.
//!
//! Use with the .NET probe:
//!
//! ```powershell
//! # Terminal A: start the relay
//! cargo run -p mxaccess --example asb-relay 2> .\dotnet.log
//!
//! # Terminal B: point the .NET probe at the relay
//! dotnet run --project src\MxAsbClient.Probe -c Release -- `
//! --endpoint "net.tcp://desktop-6jl3kko:8088/ASBService/Default_ZB_MxDataProvider/IDataV2"
//! ```
//!
//! Then run our Rust client through the relay:
//!
//! ```powershell
//! $env:MX_ASB_HOST = "127.0.0.1:8088"
//! cargo run -p mxaccess --example asb-preamble-probe 2> .\rust.log
//! ```
//!
//! Diff the two logs to find wire-byte deltas. Direction labels are
//! `C->S` (client→server) and `S->C` (server→client).
//!
//! ## Important caveat: SMSvcHost URL matching
//!
//! When the relay forwards to `127.0.0.1:808`, the SMSvcHost dispatcher
//! looks at the NMF `Via` record's URL host segment to pick which
//! registered service to route to. The .NET probe's default URL has
//! the hostname `desktop-6jl3kko`, NOT `127.0.0.1` — and SMSvcHost
//! resolves the registered service by the URL the AVEVA installer
//! recorded (which is the actual hostname). Use the actual hostname
//! in the .NET probe `--endpoint` arg even when sending through the
//! relay; only the TCP socket changes (we listen on `127.0.0.1:8088`,
//! the .NET probe's TCP DNS still resolves to localhost so it
//! connects to us, but the URL inside the preamble routes correctly
//! at SMSvcHost).
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listen_addr =
std::env::var("MX_RELAY_LISTEN").unwrap_or_else(|_| "127.0.0.1:8088".to_string());
let upstream_addr =
std::env::var("MX_RELAY_UPSTREAM").unwrap_or_else(|_| "127.0.0.1:808".to_string());
let listener = TcpListener::bind(&listen_addr).await?;
eprintln!(
"asb-relay listening on {} → forwarding to {}",
listen_addr, upstream_addr
);
eprintln!("hex prefixes: C->S = client→server, S->C = server→client");
let conn_counter = Arc::new(AtomicUsize::new(0));
loop {
let (client_stream, peer) = listener.accept().await?;
let conn_id = conn_counter.fetch_add(1, Ordering::Relaxed);
let upstream_addr = upstream_addr.clone();
eprintln!("[#{conn_id}] accepted {peer}");
tokio::spawn(async move {
if let Err(e) = handle_connection(conn_id, client_stream, &upstream_addr).await {
eprintln!("[#{conn_id}] connection error: {e}");
}
eprintln!("[#{conn_id}] closed");
});
}
}
async fn handle_connection(
conn_id: usize,
mut client: TcpStream,
upstream_addr: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut server = TcpStream::connect(upstream_addr).await?;
eprintln!("[#{conn_id}] upstream connected");
// Disable Nagle on both sides so log timing matches actual flushes.
let _ = client.set_nodelay(true);
let _ = server.set_nodelay(true);
let (mut cr, mut cw) = client.split();
let (mut sr, mut sw) = server.split();
let cs = forward(conn_id, "C->S", &mut cr, &mut sw);
let sc = forward(conn_id, "S->C", &mut sr, &mut cw);
let _ = tokio::join!(cs, sc);
Ok(())
}
async fn forward<R, W>(
conn_id: usize,
tag: &'static str,
reader: &mut R,
writer: &mut W,
) -> std::io::Result<()>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let mut buf = vec![0u8; 8192];
let mut total = 0usize;
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
break;
}
if let Some(slice) = buf.get(..n) {
print_hex(conn_id, tag, total, slice);
writer.write_all(slice).await?;
writer.flush().await?;
}
total += n;
}
let _ = writer.shutdown().await;
eprintln!("[#{conn_id}] {tag} EOF after {total} bytes");
Ok(())
}
fn print_hex(conn_id: usize, tag: &str, base_offset: usize, bytes: &[u8]) {
for (chunk_idx, chunk) in bytes.chunks(16).enumerate() {
let offset = base_offset + chunk_idx * 16;
let hex: Vec<String> = chunk.iter().map(|b| format!("{b:02x}")).collect();
let ascii: String = chunk
.iter()
.map(|b| {
if b.is_ascii_graphic() || *b == b' ' {
*b as char
} else {
'.'
}
})
.collect();
eprintln!(
"[#{conn_id}] {tag} {offset:08x} {:<48} {}",
hex.join(" "),
ascii
);
}
}
+195 -31
View File
@@ -1,44 +1,208 @@
//! `asb-subscribe` — subscribe via the ASB transport (M5 placeholder).
//! `asb-subscribe` — bring up an ASB session and exercise RegisterItems +
//! Read against a live AVEVA endpoint.
//!
//! ASB (`net.tcp` to MxDataProvider) is the M5 milestone — the
//! `mxaccess-asb-nettcp` framing crate and `mxaccess-asb` operations crate
//! are scaffolded but not yet wired into `Session`. Once M5 lands the demo
//! body below becomes a ~30-line subscribe + drain identical in shape to
//! `subscribe.rs`, just over the ASB transport.
//! Despite the example's historical name, true `Subscribe` over ASB
//! requires the F25 subscription operations (CreateSubscription /
//! AddMonitoredItems / Publish-callback) which are not yet implemented.
//! This example exercises the proven F25/F26 path:
//!
//! See `design/60-roadmap.md` M5 for the operations matrix and
//! `docs/ASB-Native-Integration-Decision.md` for why ASB is the preferred
//! data-plane.
//! `AsbTransport::connect` (TCP + preamble + DH handshake)
//! → `AsbClient::register_items`
//! → `AsbClient::read`
//! → `AsbClient::disconnect`
//! → `AsbClient::send_end`
//!
//! Once F25 subscription ops land, this example will gain a short
//! Publish-loop. Until then it's a Read-loop demo.
//!
//! # Required env vars
//!
//! Populate via `tools/Setup-LiveProbeEnv.ps1` (dot-source it):
//!
//! - `MX_LIVE` (any non-empty value enables the live path)
//! - `MX_ASB_HOST` — ASB endpoint host[:port]; defaults port 808 if omitted
//! (the WCF `NetTcpPortSharing` SMSvcHost listener — confirmed via the
//! .NET probe's working endpoint at `src/MxAsbClient.Probe/Program.cs:5`)
//! - `MX_ASB_PASSPHRASE` — solution shared secret (typically read from
//! DPAPI on a real install; for CI / dev set directly via Infisical
//! per `tools/Setup-LiveProbeEnv.ps1`)
//! - `MX_ASB_VIA` — `net.tcp://host:port/ASBService` URL (optional;
//! derived from `MX_ASB_HOST` when omitted)
//! - `MX_TEST_TAG` — tag reference (default `TestChildObject.TestInt`)
use mxaccess::{ConnectionOptions, Session};
use std::time::Duration;
use mxaccess::AsbTransport;
use mxaccess_asb::ItemIdentity;
use mxaccess_asb_nettcp::auth::{CryptoParameters, HashAlgorithm};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var_os("MX_LIVE").is_none() {
let Some(env) = LiveEnv::from_process()? else {
eprintln!(
"MX_LIVE not set — `asb-subscribe` is the M5 placeholder; \
run `. tools/Setup-LiveProbeEnv.ps1` once the ASB transport lands."
"MX_LIVE not set — skipping live demo. Run \
`. tools/Setup-LiveProbeEnv.ps1` to populate the required env vars."
);
return Ok(());
};
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\<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(
env.addr,
&env.passphrase,
&crypto,
&env.via_uri,
connection_id,
)
.await?;
eprintln!(
"connected; lifetime={:?} apollo={}",
response.connection_lifetime,
transport
.client_mut()
.authenticator_mut()
.use_apollo_signing()
);
let client = transport.client_mut();
let items = vec![ItemIdentity::absolute_by_name(&env.tag)];
eprintln!("registering {}", env.tag);
let register = client.register_items(&items, true, false).await?;
eprintln!(
"register status: {} item(s); first error_code = 0x{:04x}",
register.status.len(),
register.status.first().map(|s| s.error_code).unwrap_or(0)
);
eprintln!("reading {} (timeout 5s)", env.tag);
let read = tokio::time::timeout(Duration::from_secs(5), client.read(&items)).await??;
for (status, value) in read.status.iter().zip(read.values.iter()) {
println!(
"{} = {:?} (error_code 0x{:04x})",
status.item.name.as_deref().unwrap_or("?"),
value.value,
status.error_code
);
}
if read.values.is_empty() {
println!("{} returned no values yet (status only)", env.tag);
}
match Session::connect(ConnectionOptions).await {
Ok(_) => {
eprintln!(
"Session::connect returned Ok unexpectedly — \
update this example once M5 wires the ASB transport."
);
}
Err(mxaccess::Error::Unsupported {
operation,
transport,
}) => {
eprintln!(
"{operation} on {transport:?}: deferred to M5. See \
design/60-roadmap.md M5 for the ASB transport operations matrix."
);
}
Err(e) => return Err(e.into()),
}
eprintln!("disconnecting");
client.disconnect().await?;
client.send_end().await?;
Ok(())
}
// ---- live-env wiring --------------------------------------------------------
struct LiveEnv {
addr: std::net::SocketAddr,
passphrase: String,
via_uri: String,
tag: String,
}
impl LiveEnv {
fn from_process() -> Result<Option<Self>, Box<dyn std::error::Error>> {
if std::env::var_os("MX_LIVE").is_none() {
return Ok(None);
}
let host = std::env::var("MX_ASB_HOST")?;
let addr = parse_host_port(&host, 808)?;
let passphrase = std::env::var("MX_ASB_PASSPHRASE")
.map_err(|_| "MX_ASB_PASSPHRASE not set — ASB requires the solution shared secret")?;
let via_uri =
std::env::var("MX_ASB_VIA").unwrap_or_else(|_| format!("net.tcp://{host}/ASBService"));
let tag = std::env::var("MX_TEST_TAG").unwrap_or_else(|_| "TestChildObject.TestInt".into());
Ok(Some(Self {
addr,
passphrase,
via_uri,
tag,
}))
}
}
fn parse_host_port(
s: &str,
default_port: u16,
) -> Result<std::net::SocketAddr, Box<dyn std::error::Error>> {
if let Ok(addr) = s.parse() {
return Ok(addr);
}
let with_port = if s.contains(':') {
s.to_string()
} else {
format!("{s}:{default_port}")
};
Ok(
std::net::ToSocketAddrs::to_socket_addrs(&with_port.as_str())?
.next()
.ok_or("no addrs resolved")?,
)
}
/// Generate a fresh 16-byte connection-id GUID for this session. We
/// could pull `uuid::Uuid::new_v4()` for a real RFC 4122 v4, but the
/// example deliberately stays dep-light — `rand::random::<[u8; 16]>()`
/// is sufficient since the field is opaque to the service (the .NET
/// reference at `MxAsbDataClient.cs:36` uses `Guid.NewGuid()` which
/// is also a v4 random GUID).
fn generate_connection_id() -> [u8; 16] {
use rand::RngCore;
let mut bytes = [0u8; 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\<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
}
+319
View File
@@ -0,0 +1,319 @@
//! `AsbSession` — high-level async API on top of [`crate::AsbTransport`].
//!
//! Parallel to the NMX-shaped [`crate::Session`] but with an
//! ASB-specific API surface: register/read/write items, manage
//! subscriptions, drain publish callbacks, disconnect cleanly. The
//! struct is `Clone + Send + Sync` (cheap clones share the inner
//! state through `Arc<Mutex<...>>`), matching the ergonomics of
//! `Session`.
//!
//! Why a parallel struct rather than unifying with `Session`: the NMX
//! `Session` carries NMX-specific orchestration (`CallbackExporter`,
//! callback router task, recovery broadcast, `INmxService2` mutex)
//! that has no ASB analogue. ASB's request/response loop is sync over
//! a single TCP stream — owning it through a `Mutex<AsbClient>` is
//! natural for ASB and would be foreign to NMX. The two paths
//! converge at the `mxaccess` consumer-facing API but stay distinct
//! at the orchestration layer.
//!
//! ## Scope of this iteration (F26 step 3)
//!
//! Implements:
//! * [`AsbSession::connect`] — TCP connect → preamble → DH handshake
//! → ready session.
//! * [`AsbSession::register_items`] / [`unregister_items`] /
//! [`read`] / [`write`] — per-operation thin async wrappers.
//! * [`AsbSession::keep_alive`] / [`disconnect`] / [`shutdown`] —
//! lifecycle.
//! * [`AsbSession::create_subscription`] /
//! [`add_monitored_items`] / [`publish`] /
//! [`delete_monitored_items`] / [`delete_subscription`] —
//! subscription primitives.
//! * Cheap-clone semantics — the inner state lives behind
//! `Arc<Mutex<...>>`, so `clone()` is `O(1)` and the lock
//! serialises operation calls (matches the NMX Session's pattern
//! per `session.rs:326`).
//!
//! Stubbed for next F26 iteration:
//! * `Stream<Item = MonitoredItemValue>` subscription handle that
//! internally drives a `publish`-loop. Today consumers call
//! `publish().await` themselves in a loop.
//! * Recovery / reconnect — the NMX `RecoveryPolicy` shape needs to
//! be reused once a captured ASB-side disconnect informs the
//! retry strategy.
//! * Live-probe wire-byte reconciliation against the WCF DataContract
//! XML serializer's actual output — flagged in `mxaccess-asb`
//! inline.
use std::net::SocketAddr;
use std::sync::Arc;
use mxaccess_asb::{
AddMonitoredItemsResponse, ConnectResponse, CreateSubscriptionResponse,
DeleteMonitoredItemsResponse, DeleteSubscriptionResponse, ItemIdentity, MinimalMonitoredItem,
MinimalWriteValue, PublishResponse, PublishWriteCompleteResponse, ReadResponse,
RegisterItemsResponse, UnregisterItemsResponse, WriteResponse,
};
use mxaccess_asb_nettcp::auth::CryptoParameters;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use crate::transport_asb::AsbTransport;
use crate::{ConnectionError, Error};
/// Cheap-clone async client for the ASB data plane. Drop of the last
/// clone fires a best-effort `disconnect()` + `send_end()` per the
/// `Drop` impl below.
#[derive(Clone)]
pub struct AsbSession {
inner: Arc<AsbSessionInner>,
}
struct AsbSessionInner {
transport: Mutex<AsbTransport<TcpStream>>,
/// Negotiated connection lifetime / `:V2` Apollo flag from the
/// initial Connect handshake. Stable for the life of the session.
#[allow(dead_code)]
connect_response: ConnectResponse,
}
impl std::fmt::Debug for AsbSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AsbSession").finish_non_exhaustive()
}
}
impl AsbSession {
/// Open a TCP connection, run the NMF preamble + DH handshake, and
/// return a ready-to-use session.
///
/// `passphrase` is the solution shared secret (typically read
/// from DPAPI on a real install — see F23's `dpapi` feature gate
/// in `mxaccess-asb-nettcp`). `crypto_parameters` controls the
/// DH prime / generator / hash algorithm; pass
/// [`CryptoParameters::defaults`] for a stock AVEVA install.
/// `connection_id` should be a freshly-generated UUID.
pub async fn connect(
endpoint: SocketAddr,
passphrase: &str,
crypto_parameters: &CryptoParameters,
via_uri: impl Into<String>,
connection_id: [u8; 16],
) -> Result<Self, Error> {
let (transport, connect_response) = AsbTransport::connect(
endpoint,
passphrase,
crypto_parameters,
via_uri,
connection_id,
)
.await?;
Ok(Self {
inner: Arc::new(AsbSessionInner {
transport: Mutex::new(transport),
connect_response,
}),
})
}
/// Build from an already-constructed [`AsbTransport`] +
/// [`ConnectResponse`]. Useful for tests using an in-memory
/// transport, and for the F26 step-2 path that surfaces the
/// transport for caller customisation before promoting to a
/// session.
pub fn from_transport(
transport: AsbTransport<TcpStream>,
connect_response: ConnectResponse,
) -> Self {
Self {
inner: Arc::new(AsbSessionInner {
transport: Mutex::new(transport),
connect_response,
}),
}
}
/// Borrow the negotiated connect response — useful for inspecting
/// the connection lifetime or whether Apollo signing was selected.
pub fn connect_response(&self) -> &ConnectResponse {
&self.inner.connect_response
}
/// `RegisterItems` — server allocates per-item handles + returns
/// per-item Status array. Mirrors `MxAsbDataClient.Register`.
pub async fn register_items(
&self,
items: &[ItemIdentity],
require_id: bool,
register_only: bool,
) -> Result<RegisterItemsResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.register_items(items, require_id, register_only)
.await
.map_err(map_client_error)
}
/// `UnregisterItems` — releases server-side per-item handles.
pub async fn unregister_items(
&self,
items: &[ItemIdentity],
) -> Result<UnregisterItemsResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.unregister_items(items)
.await
.map_err(map_client_error)
}
/// `Read` — fetch the current value of each item.
pub async fn read(&self, items: &[ItemIdentity]) -> Result<ReadResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client.read(items).await.map_err(map_client_error)
}
/// `Write` — set the value of each item. `items.len()` should
/// equal `values.len()`; `write_handle` is an opaque correlation
/// ID echoed back via `publish_write_complete`.
pub async fn write(
&self,
items: &[ItemIdentity],
values: &[MinimalWriteValue],
write_handle: u32,
) -> Result<WriteResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.write(items, values, write_handle)
.await
.map_err(map_client_error)
}
/// `KeepAlive` — one-way heartbeat to keep the channel alive
/// past the WCF inactivity timeout (~30s).
pub async fn keep_alive(&self) -> Result<(), Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client.keep_alive().await.map_err(map_client_error)
}
/// `CreateSubscription` — server allocates a subscription, returns
/// its ID for use with `add_monitored_items` / `publish` /
/// `delete_subscription`.
pub async fn create_subscription(
&self,
max_queue_size: i64,
sample_interval: u64,
) -> Result<CreateSubscriptionResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.create_subscription(max_queue_size, sample_interval)
.await
.map_err(map_client_error)
}
/// `AddMonitoredItems` — adds items to a subscription.
pub async fn add_monitored_items(
&self,
subscription_id: i64,
items: &[MinimalMonitoredItem],
require_id: bool,
) -> Result<AddMonitoredItemsResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.add_monitored_items(subscription_id, items, require_id)
.await
.map_err(map_client_error)
}
/// `Publish` — long-poll the subscription queue for available
/// samples. Caller typically loops this with a `tokio::time::timeout`.
pub async fn publish(&self, subscription_id: i64) -> Result<PublishResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.publish(subscription_id)
.await
.map_err(map_client_error)
}
/// `DeleteMonitoredItems` — remove items from a subscription.
pub async fn delete_monitored_items(
&self,
subscription_id: i64,
items: &[MinimalMonitoredItem],
) -> Result<DeleteMonitoredItemsResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.delete_monitored_items(subscription_id, items)
.await
.map_err(map_client_error)
}
/// `DeleteSubscription` — release a server-side subscription.
pub async fn delete_subscription(
&self,
subscription_id: i64,
) -> Result<DeleteSubscriptionResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.delete_subscription(subscription_id)
.await
.map_err(map_client_error)
}
/// `PublishWriteComplete` — drain the write-complete callback
/// queue. Returns a count of completed writes (per-element decode
/// is deferred to a later iteration).
pub async fn publish_write_complete(&self) -> Result<PublishWriteCompleteResponse, Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client
.publish_write_complete()
.await
.map_err(map_client_error)
}
/// `Disconnect` — graceful close. Sends a signed Disconnect
/// envelope, then writes the NMF `End` record + shuts down the
/// stream.
pub async fn disconnect(&self) -> Result<(), Error> {
let mut transport = self.inner.transport.lock().await;
let client = transport.client_mut();
client.disconnect().await.map_err(map_client_error)?;
client.send_end().await.map_err(map_client_error)?;
Ok(())
}
}
fn map_client_error(err: mxaccess_asb::ClientError) -> Error {
Error::Connection(ConnectionError::TransportFailure {
detail: err.to_string(),
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
/// Compile-time only: `AsbSession` must be `Clone + Send + Sync`
/// (the `mxaccess` consumer ergonomics contract).
#[test]
fn asb_session_is_clone_send_sync() {
fn assert_clone_send_sync<T: Clone + Send + Sync + 'static>() {}
assert_clone_send_sync::<AsbSession>();
}
}
+12
View File
@@ -29,7 +29,12 @@ pub use mxaccess_codec::{
// ---- Public types --------------------------------------------------------
pub mod asb_session;
pub mod session;
pub mod transport_asb;
pub use asb_session::AsbSession;
pub use transport_asb::AsbTransport;
pub use mxaccess_galaxy::{GalaxyTagMetadata, Resolver, ResolverError};
pub use mxaccess_nmx::WriteValue;
@@ -298,6 +303,13 @@ pub enum ConnectionError {
CallbackProxyMissing,
#[error("engine not registered (UninitializedObject / ERROR_INVALID_STATE)")]
EngineNotRegistered,
/// Transport bring-up failed during preamble exchange or
/// authentication handshake. `detail` is the underlying error
/// message — the original error type is intentionally erased to
/// keep the public taxonomy small. ASB-specific (F26 step 2);
/// `EngineNotRegistered` covers the analogous NMX failure mode.
#[error("transport bring-up failed: {detail}")]
TransportFailure { detail: String },
}
#[derive(Debug, thiserror::Error)]
+217
View File
@@ -0,0 +1,217 @@
//! `AsbTransport` — bridges the F25 `mxaccess_asb::AsbClient` into the
//! `mxaccess::Transport` trait + `Session` API.
//!
//! Per `design/60-roadmap.md` M5, the ASB transport surfaces:
//!
//! * **No `subscribe_buffered`** — ASB has no proven equivalent of NMX's
//! buffered-batch DataUpdate frame; consumers calling
//! `Session::subscribe_buffered` over ASB get
//! `Error::Unsupported(Capability::BufferedSubscribe)`.
//! * **No `Activate` / `Suspend`** — these are NMX `INmxService2`
//! primitives without an ASB analogue.
//! * **No `OperationComplete` outside the proven write-completion frame**
//! — ASB doesn't surface a generic completion-frame channel.
//!
//! ## Scope of this iteration (F26 step 1)
//!
//! Implements:
//! * [`AsbTransport`] struct that owns an [`AsbClient`] over an
//! `AsyncRead + AsyncWrite + Unpin + Send` transport.
//! * [`Transport`] trait impl returning the capability flags above.
//! * [`AsbTransport::new`] constructor.
//!
//! Stubbed for next F26 iteration:
//! * `Session::connect_asb` constructor — wires `AsbTransport` into a
//! `Session`. Needs a thin shim that owns the AsbClient + delegates
//! `register_items`/`read`/`write`/`subscribe` to the corresponding
//! client method, mapping ASB result types (`ItemStatus`,
//! `RuntimeValue`) back to `mxaccess` types (`MxStatus`,
//! `DataChange`, `MxValue`).
//! * Subscription routing — `Session::subscribe` on ASB maps to a
//! `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback
//! pipeline; the F25 subscription operations are not yet wired up.
use std::net::SocketAddr;
use mxaccess_asb::{AsbClient, ClientError, ConnectResponse};
use mxaccess_asb_nettcp::auth::{AsbAuthenticator, CryptoParameters};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use crate::{Error, Transport, TransportCapabilities, TransportKind};
/// `Transport` implementation for the ASB (`net.tcp` + binary-message-
/// encoder) data plane. Owns the underlying [`AsbClient`].
pub struct AsbTransport<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> {
client: AsbClient<T>,
}
impl<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> AsbTransport<T> {
/// Build a transport from an already-constructed [`AsbClient`].
/// The client should typically have completed
/// `send_preamble().await? -> connect().await?` before being
/// wrapped — the F26 next-step `Session::connect_asb` will own that
/// orchestration.
pub fn new(client: AsbClient<T>) -> Self {
Self { client }
}
/// Surface the inner client. M5 / F26 step 2 wires concrete
/// operations through here.
pub fn client_mut(&mut self) -> &mut AsbClient<T> {
&mut self.client
}
/// Consume the transport and return the inner client. Useful when
/// the caller wants to issue raw IASBIDataV2 operations directly
/// before / after the Session-level orchestration kicks in.
pub fn into_client(self) -> AsbClient<T> {
self.client
}
}
impl AsbTransport<TcpStream> {
/// `tokio::net::TcpStream`-specialised constructor: opens the TCP
/// connection, runs the F20 preamble exchange, and runs the F25
/// step-6 DH `Connect` + `AuthenticateMe` handshake. Returns a
/// transport ready for operation calls.
///
/// The `via_uri` is the `net.tcp://host:port/path` URL the peer
/// expects in the [MS-NMF] `ViaRecord`. `passphrase` is the
/// solution-shared secret (typically read from DPAPI on a real
/// install — see F23's `dpapi` feature gate; tests / CI pass it
/// directly via `AsbCredentials::shared_secret(...)` once that
/// type lands).
///
/// `crypto_parameters` controls the DH prime / generator / hash
/// algorithm; pass [`CryptoParameters::defaults`] for a stock
/// AVEVA install.
///
/// `connection_id` should typically be a freshly-generated UUID
/// (e.g. `Uuid::new_v4().into_bytes()`). Tests pin it for
/// determinism.
///
/// # Errors
///
/// Surfaces all transport-bring-up failure modes as
/// [`Error::Connection`]:
/// * TCP connect fails.
/// * NMF preamble exchange fails (peer responded with `Fault` or
/// an unexpected record).
/// * DH `Connect` operation fails.
/// * Encrypted authentication-data assembly fails.
/// * `AuthenticateMe` write fails.
pub async fn connect(
endpoint: SocketAddr,
passphrase: &str,
crypto_parameters: &CryptoParameters,
via_uri: impl Into<String>,
connection_id: [u8; 16],
) -> Result<(Self, ConnectResponse), Error> {
let stream = TcpStream::connect(endpoint).await.map_err(Error::Io)?;
let authenticator = AsbAuthenticator::new(passphrase, crypto_parameters, connection_id)
.map_err(map_auth_error)?;
let mut client = AsbClient::new(stream, authenticator, via_uri);
client.send_preamble().await.map_err(map_client_error)?;
let response = client.connect().await.map_err(map_client_error)?;
Ok((Self::new(client), response))
}
}
fn map_client_error(err: ClientError) -> Error {
use crate::ConnectionError;
Error::Connection(ConnectionError::TransportFailure {
detail: err.to_string(),
})
}
fn map_auth_error(err: mxaccess_asb_nettcp::auth::AuthError) -> Error {
use crate::ConnectionError;
Error::Connection(ConnectionError::TransportFailure {
detail: err.to_string(),
})
}
/// Compile-time only: `AsbTransport` must be `Send + Sync + 'static`
/// (the `Transport` trait bound). Sync is provided by `AsbClient`'s
/// internal lack of interior mutability over non-Sync types — the
/// `AsyncRead + AsyncWrite + Unpin + Send` transport is the only
/// non-trivial constraint, and Tokio's `TcpStream` satisfies it.
const _: fn() = || {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<AsbTransport<tokio::io::DuplexStream>>();
};
impl<T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static> Transport for AsbTransport<T> {
fn capabilities(&self) -> TransportCapabilities {
TransportCapabilities {
// ASB has no proven buffered-batch DataUpdate equivalent.
buffered_subscribe: false,
// Activate/Suspend are NMX `INmxService2` primitives.
activate_suspend: false,
// No generic completion-frame channel on ASB.
operation_complete_frame: false,
}
}
fn kind(&self) -> TransportKind {
TransportKind::Asb
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use mxaccess_asb_nettcp::auth::{AsbAuthenticator, CryptoParameters};
fn make_authenticator() -> AsbAuthenticator {
AsbAuthenticator::new("test-passphrase", &CryptoParameters::defaults(), [0u8; 16]).unwrap()
}
#[test]
fn asb_transport_kind_is_asb() {
let (client_end, _peer) = tokio::io::duplex(64);
let client = AsbClient::new(client_end, make_authenticator(), "test://x/y");
let transport = AsbTransport::new(client);
assert_eq!(transport.kind(), TransportKind::Asb);
}
#[tokio::test]
async fn connect_to_unreachable_endpoint_surfaces_connection_error() {
// Bind to a port that won't accept connections. Address
// 127.0.0.1:1 is reserved (TCPMUX) and almost always closed,
// so connect() should fail immediately. Whether it surfaces
// as Io or Connection depends on the platform; we just assert
// that it errors cleanly without panicking.
let endpoint = "127.0.0.1:1".parse::<std::net::SocketAddr>().unwrap();
let result = AsbTransport::<TcpStream>::connect(
endpoint,
"test-passphrase",
&CryptoParameters::defaults(),
"net.tcp://127.0.0.1:1/asb",
[0u8; 16],
)
.await;
assert!(
result.is_err(),
"expected connect to unreachable endpoint to fail"
);
}
#[test]
fn asb_transport_capabilities_disable_buffered_and_activate_suspend() {
let (client_end, _peer) = tokio::io::duplex(64);
let client = AsbClient::new(client_end, make_authenticator(), "test://x/y");
let transport = AsbTransport::new(client);
let caps = transport.capabilities();
assert!(!caps.buffered_subscribe);
assert!(!caps.activate_suspend);
assert!(!caps.operation_complete_frame);
}
}
+264 -2
View File
@@ -3,6 +3,11 @@ using System.Globalization;
string endpoint = GetArg(args, "--endpoint")
?? "net.tcp://desktop-6jl3kko/ASBService/Default_ZB_MxDataProvider/IDataV2";
// `--via` overrides the TCP destination without changing the `<a:To>`
// SOAP header (so the registered service URL still matches inside
// SMSvcHost). Use to route the probe through `asb-relay` for wire
// byte capture, e.g. `--via net.tcp://127.0.0.1:8088/...`.
string? clientVia = GetArg(args, "--via");
string[] tags = GetArgs(args, "--tag");
if (tags.Length == 0)
{
@@ -53,11 +58,261 @@ 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,
};
// 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,
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;
}
// `--dump-deterministic-hmac` runs the AuthenticateMe sign path with
// FIXED inputs end-to-end (no randomness): pinned passphrase, prime,
// generator, private-key bytes, remote-pub bytes, connection ID,
// message number, AES IV, and consumer-data/IV bytes. Output is the
// resulting crypto_key, AES key, canonical XML, HMAC-SHA1, and
// AES-CBC-encrypted MAC. The Rust port uses these as a fixture for a
// byte-equality unit test that localises any HMAC/AES/derivation
// divergence vs the .NET reference without depending on session
// randomness. Mirrors the per-step decomposition of `AsbSystemAuthent
// icator.Sign` (`AsbSystemAuthenticator.cs:62-82`) but inlines the
// math so we control every byte of input.
if (args.Any(arg => arg.Equals("--dump-deterministic-hmac", StringComparison.OrdinalIgnoreCase)))
{
System.Numerics.BigInteger prime = System.Numerics.BigInteger.Parse(AsbSolutionCryptoParameters.DefaultPrimeText);
System.Numerics.BigInteger generator = 22;
// 33 bytes: 0x01..0x20 with trailing 0x00 sign byte. Mirrors the
// shape `AsbSystemAuthenticator.CreatePrivateKey` produces.
byte[] privateKeyBytes = new byte[33];
for (int i = 0; i < 32; i++) { privateKeyBytes[i] = (byte)(i + 1); }
privateKeyBytes[32] = 0x00;
// Remote public key — 128 bytes (1024-bit), high bit clear so
// .NET's BigInteger LE-two's-complement reads it as positive
// without a sign-byte fix-up.
byte[] remotePub = new byte[128];
for (int i = 0; i < 127; i++) { remotePub[i] = (byte)((i * 7 + 13) & 0xFF); }
remotePub[127] = 0x7F;
string passphrase = "deterministic-hmac-fixture-passphrase-rust-vs-dotnet";
Guid connectionId = Guid.Parse("8cba964a-74c1-ef74-f6aa-761b3540191b");
ulong messageNumber = 42;
// ConsumerAuthenticationData payload. Encrypted bytes are opaque
// to the HMAC test (they get base64-embedded in the XML and
// signed); use deterministic bytes 0x80..0xFF + 0x00..0x4F (208
// bytes — same as a real AuthenticateMe under a 768-bit prime).
byte[] consumerData = new byte[208];
for (int i = 0; i < 208; i++) { consumerData[i] = (byte)((i * 3 + 7) & 0xFF); }
byte[] consumerIv = new byte[16];
for (int i = 0; i < 16; i++) { consumerIv[i] = (byte)((i * 11 + 5) & 0xFF); }
// Deterministic AES IV for encrypting the HMAC. We pick all-zeros
// so the Rust test can reproduce without a random-IV injection
// hack. (The real wire path uses a random IV per call; here we
// bypass that to make the test reproducible.)
byte[] aesIv = new byte[16];
// ---- crypto_key = shared_secret || passphrase_utf8 ----------
System.Numerics.BigInteger sharedValue = System.Numerics.BigInteger.ModPow(
new System.Numerics.BigInteger(remotePub),
new System.Numerics.BigInteger(privateKeyBytes),
prime);
byte[] shared = sharedValue.ToByteArray();
byte[] cryptoKey = [.. shared, .. System.Text.Encoding.UTF8.GetBytes(passphrase)];
// ---- canonical XML (empty MAC + IV) -------------------------
AuthenticateMe req = new()
{
ConnectionValidator = new()
{
ConnectionId = connectionId,
MessageNumber = messageNumber,
MessageAuthenticationCode = [],
SignatureInitializationVector = [],
},
ConsumerAuthenticationData = new AuthenticationData
{
Data = consumerData,
InitializationVector = consumerIv,
},
};
string xmlText = req.ToXml();
byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(xmlText);
// ---- HMAC-SHA1(crypto_key, xml_utf8) ------------------------
using System.Security.Cryptography.HMACSHA1 hmac = new(cryptoKey);
byte[] hash = hmac.ComputeHash(xmlBytes);
// ---- AES key = PBKDF2-SHA1(base64(crypto_key), salt, 1000) --
byte[] salt = System.Text.Encoding.ASCII.GetBytes("ArchestrAService");
byte[] aesKey = System.Security.Cryptography.Rfc2898DeriveBytes.Pbkdf2(
Convert.ToBase64String(cryptoKey),
salt,
iterations: 1000,
System.Security.Cryptography.HashAlgorithmName.SHA1,
outputLength: 16);
// ---- AES-CBC encrypt(hash) with fixed IV --------------------
byte[] encryptedMac;
using (System.Security.Cryptography.Aes aes = System.Security.Cryptography.Aes.Create())
{
aes.Key = aesKey;
aes.IV = aesIv;
// CBC mode, PKCS7 padding (defaults).
using System.IO.MemoryStream ms = new();
using (System.Security.Cryptography.CryptoStream cs = new(
ms,
aes.CreateEncryptor(),
System.Security.Cryptography.CryptoStreamMode.Write))
{
cs.Write(hash, 0, hash.Length);
}
encryptedMac = ms.ToArray();
}
Console.WriteLine("# deterministic-hmac fixture (.NET reference output)");
Console.WriteLine($"prime_decimal={prime}");
Console.WriteLine($"generator={generator}");
Console.WriteLine($"private_key_hex={Convert.ToHexString(privateKeyBytes)}");
Console.WriteLine($"remote_pub_hex={Convert.ToHexString(remotePub)}");
Console.WriteLine($"passphrase={passphrase}");
Console.WriteLine($"connection_id={connectionId:D}");
Console.WriteLine($"message_number={messageNumber}");
Console.WriteLine($"consumer_data_hex={Convert.ToHexString(consumerData)}");
Console.WriteLine($"consumer_iv_hex={Convert.ToHexString(consumerIv)}");
Console.WriteLine($"aes_iv_hex={Convert.ToHexString(aesIv)}");
Console.WriteLine($"shared_secret_hex={Convert.ToHexString(shared)}");
Console.WriteLine($"shared_secret_len={shared.Length}");
Console.WriteLine($"crypto_key_hex={Convert.ToHexString(cryptoKey)}");
Console.WriteLine($"crypto_key_len={cryptoKey.Length}");
Console.WriteLine($"xml_utf8_len={xmlBytes.Length}");
Console.WriteLine($"xml_utf8_b64={Convert.ToBase64String(xmlBytes)}");
Console.WriteLine($"hmac_sha1_hex={Convert.ToHexString(hash)}");
Console.WriteLine($"aes_key_hex={Convert.ToHexString(aesKey)}");
Console.WriteLine($"encrypted_mac_hex={Convert.ToHexString(encryptedMac)}");
Console.WriteLine($"encrypted_mac_len={encryptedMac.Length}");
return;
}
if (probeConnectFailure)
{
try
{
using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages);
using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(new AsbConnectionOptions
{
Endpoint = endpoint,
SolutionName = solution,
Trace = Console.WriteLine,
DumpMessages = dumpMessages,
Via = clientVia,
});
Console.WriteLine("connect_failure_observed=False");
}
catch (Exception ex)
@@ -79,7 +334,14 @@ if (compatibilitySubscribe)
return;
}
using MxAsbDataClient client = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages);
using MxAsbDataClient client = MxAsbDataClient.Connect(new AsbConnectionOptions
{
Endpoint = endpoint,
SolutionName = solution,
Trace = Console.WriteLine,
DumpMessages = dumpMessages,
Via = clientVia,
});
int publishedEventCount = 0;
client.PublishedValueReceived += (_, value) =>
{
+10
View File
@@ -10,6 +10,16 @@ public sealed record AsbConnectionOptions
public bool DumpMessages { get; init; }
/// <summary>
/// Optional `ClientVia` URL — if set, WCF will TCP-connect to this
/// URL but address messages with the `Endpoint` URL (so the
/// `&lt;a:To&gt;` SOAP header still matches the registered service).
/// Used to route the .NET probe through the `asb-relay` middleman
/// without triggering an `AddressFilterMismatch` fault. Mirrors
/// `System.ServiceModel.Description.ClientViaBehavior`.
/// </summary>
public string? Via { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Endpoint))
+26 -3
View File
@@ -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<string>? sharedTrace;
public AsbSystemAuthenticator(string passphrase, AsbSolutionCryptoParameters cryptoParameters, Action<string>? 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)
@@ -133,12 +150,18 @@ internal sealed class AsbSystemAuthenticator
private byte[] DeriveAesKey()
{
return Rfc2898DeriveBytes.Pbkdf2(
Convert.ToBase64String(CryptoKey),
byte[] cryptoKey = CryptoKey;
byte[] aesKey = Rfc2898DeriveBytes.Pbkdf2(
Convert.ToBase64String(cryptoKey),
PasswordSalt,
iterations: 1000,
HashAlgorithmName.SHA1,
outputLength: 16);
sharedTrace?.Invoke($"asb.derive.crypto_key.len={cryptoKey.Length}");
sharedTrace?.Invoke($"asb.derive.crypto_key.hex={Convert.ToHexString(cryptoKey)}");
sharedTrace?.Invoke($"asb.derive.crypto_key.b64={Convert.ToBase64String(cryptoKey)}");
sharedTrace?.Invoke($"asb.derive.aes_key.hex={Convert.ToHexString(aesKey)}");
return aesKey;
}
private byte[] CryptoKey
+5
View File
@@ -14,4 +14,9 @@
<PackageReference Include="System.ServiceModel.Primitives" Version="10.0.652802" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MxAsbClient.Probe" />
<InternalsVisibleTo Include="MxAsbClient.Tests" />
</ItemGroup>
</Project>
+16 -2
View File
@@ -68,7 +68,19 @@ public sealed class MxAsbDataClient : IDisposable
AsbSystemAuthenticator authenticator = new(passphrase, cryptoParameters, trace);
trace?.Invoke("asb.stage=authenticator-ready");
NetTcpBinding binding = CreateBinding();
ChannelFactory<IAsbDataV2> factory = new(binding, new EndpointAddress(endpoint));
// Optional `ClientVia`: when set, TCP-connect to that URL but
// keep the `<a:To>` header pointing at the registered Endpoint.
// Used to route through `asb-relay` for byte-level capture
// without triggering an `AddressFilterMismatch` fault. CoreWCF /
// .NET 10 dropped `ClientViaBehavior`; the equivalent is to
// pass the Via URL through to `CreateChannel(addr, viaUri)`.
EndpointAddress endpointAddress = new(endpoint);
ChannelFactory<IAsbDataV2> factory = new(binding, endpointAddress);
Uri? viaUri = string.IsNullOrWhiteSpace(options.Via) ? null : new Uri(options.Via);
if (viaUri is not null)
{
trace?.Invoke($"asb.client_via={viaUri}");
}
AsbDataCustomSerializer.Trace = dumpMessages ? trace : null;
int replacedSerializers = AsbCustomSerializerContractBehavior.ReplaceSerializer(factory.Endpoint.Contract);
trace?.Invoke($"asb.serializer.behaviors-replaced={replacedSerializers}");
@@ -83,7 +95,9 @@ public sealed class MxAsbDataClient : IDisposable
{
trace?.Invoke("asb.stage=open-factory");
factory.Open();
channel = factory.CreateChannel();
channel = viaUri is not null
? factory.CreateChannel(endpointAddress, viaUri)
: factory.CreateChannel();
trace?.Invoke("asb.stage=open-channel");
clientChannel = (IClientChannel)channel;
+205
View File
@@ -0,0 +1,205 @@
# Get-AsbPassphrase.ps1 — read the ASB solution shared secret from the local
# Windows registry + DPAPI and export the env vars the Rust port's
# `asb-subscribe` example expects.
#
# Mirrors `src/MxAsbClient/AsbRegistry.cs:21-41`:
# 1. Look up the default solution name at
# HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\DefaultASBSolution
# (or the value passed via -SolutionName).
# 2. Read the `sharedsecret` REG_BINARY at
# HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\sharedsecret.
# 3. DPAPI-decrypt the bytes (LocalMachine scope, entropy = "wonderware"
# UTF-16LE-encoded — the .NET reference's `Entropy` constant at
# `AsbRegistry.cs:10`).
# 4. UTF-16LE-decode the cleartext into the passphrase string.
# 5. Export $env:MX_ASB_PASSPHRASE plus convenience-derived $env:MX_ASB_HOST
# and $env:MX_ASB_VIA. Sets $env:MX_LIVE=1 to enable the example's
# live path.
#
# Hard rules:
# - Plaintext passphrase NEVER printed to the console (use -Show to opt in).
# - DPAPI scope is LocalMachine; the caller's Windows account must be
# authorised against the encrypted blob (typically: any local
# Administrator or the AVEVA service account).
# - Dot-source so env vars persist in the calling pwsh session.
#
# Usage:
# . .\tools\Get-AsbPassphrase.ps1 # default solution from registry
# . .\tools\Get-AsbPassphrase.ps1 -SolutionName 'Default_ZB_MxDataProvider'
# . .\tools\Get-AsbPassphrase.ps1 -GalaxyName 'ZB' # builds MX_ASB_VIA
# .\tools\Get-AsbPassphrase.ps1 -DryRun # print what would be set
# .\tools\Get-AsbPassphrase.ps1 -Show # print plaintext (CI / debug only!)
[CmdletBinding()]
param(
[string]$SolutionName,
[string]$GalaxyName = 'ZB',
[string]$AsbHost = $env:COMPUTERNAME,
[switch]$DryRun,
[switch]$Show
)
$ErrorActionPreference = 'Stop'
# Mirror `AsbRegistry.RegistryPath` at `cs:12-14`. The registry layout uses
# the WoW64 redirector path on 64-bit hosts because the AVEVA service that
# wrote the value runs as 32-bit.
$ServicesKeyPath = if ([Environment]::Is64BitOperatingSystem) {
'HKLM:\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices'
} else {
'HKLM:\SOFTWARE\ArchestrA\ArchestrAServices'
}
# DPAPI entropy — `AsbRegistry.cs:10`.
$DpapiEntropy = [System.Text.Encoding]::Unicode.GetBytes('wonderware')
function Resolve-AsbSolutionName {
param([string]$Override)
if ($Override) {
return $Override
}
if (-not (Test-Path $ServicesKeyPath)) {
throw "ArchestrAServices registry key not found at $ServicesKeyPath. Is AVEVA System Platform installed?"
}
$default = (Get-ItemProperty -Path $ServicesKeyPath -ErrorAction Stop).DefaultASBSolution
if (-not $default) {
throw "DefaultASBSolution registry value is empty under $ServicesKeyPath. Pass -SolutionName explicitly."
}
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"
if (-not (Test-Path $path)) {
throw "Solution registry key not found at $path. Solution=$Solution may be misspelt."
}
$value = (Get-ItemProperty -Path $path -ErrorAction Stop).sharedsecret
if (-not $value) {
throw "sharedsecret value missing under $path."
}
if ($value -isnot [byte[]]) {
throw "sharedsecret value at $path is not REG_BINARY (got $($value.GetType().Name))."
}
return $value
}
function Unprotect-AsbSecret {
param([byte[]]$Protected)
Add-Type -AssemblyName System.Security
try {
$clear = [System.Security.Cryptography.ProtectedData]::Unprotect(
$Protected,
$DpapiEntropy,
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
)
} catch {
throw "DPAPI decrypt failed: $_. Possible causes: this account isn't authorised against the LocalMachine-scope blob; the AVEVA service was provisioned under a different machine identity; the sharedsecret bytes are corrupt."
}
return [System.Text.Encoding]::Unicode.GetString($clear).TrimEnd("`0")
}
function Set-LiveEnvVar {
param([string]$Name, [string]$Value, [switch]$Sensitive)
$display = if ($Sensitive -and -not $Show) { '***redacted***' } else { $Value }
if ($DryRun) {
Write-Host "[DRY] $Name = $display" -ForegroundColor Yellow
} else {
Set-Item -Path "Env:$Name" -Value $Value
Write-Host "[SET] $Name = $display" -ForegroundColor Green
}
}
# --- main flow -------------------------------------------------------------
Write-Host 'mxaccess ASB passphrase loader' -ForegroundColor Cyan
Write-Host " registry: $ServicesKeyPath" -ForegroundColor DarkGray
$solution = Resolve-AsbSolutionName -Override $SolutionName
Write-Host " solution: $solution" -ForegroundColor DarkGray
$protected = Get-AsbSharedSecretBytes -Solution $solution
Write-Host " sharedsecret bytes: $($protected.Length) (DPAPI-protected)" -ForegroundColor DarkGray
$passphrase = Unprotect-AsbSecret -Protected $protected
Write-Host " passphrase chars: $($passphrase.Length) (decrypted)" -ForegroundColor DarkGray
Write-Host ''
Set-LiveEnvVar -Name 'MX_LIVE' -Value '1'
Set-LiveEnvVar -Name 'MX_ASB_HOST' -Value $AsbHost
# The endpoint URL targets the per-Galaxy MxDataProvider WCF service —
# `Default_<galaxy>_MxDataProvider`. That's a DIFFERENT name from the
# system-wide ArchestrA solution that owns the sharedsecret (typically
# `Archestra_<HOST>`); both live under the same registry root but the
# .NET probe at `src/MxAsbClient.Probe/Program.cs:5` hardcodes the
# MxDataProvider segment because that's what serves IASBIDataV2.
$mxDataProvider = "Default_${GalaxyName}_MxDataProvider"
# Lowercase the host segment of the URL — WCF's NetTcpPortSharing
# SMSvcHost matches the registered service URL case-sensitively in
# the host part; the .NET probe at `src/MxAsbClient.Probe/Program.cs:5`
# hardcodes the lowercase form (`desktop-6jl3kko`) which is what
# AVEVA actually registered. We keep $AsbHost as-cased for TCP DNS
# resolution (`MX_ASB_HOST`) but lowercase it for the Via URL.
$viaHost = $AsbHost.ToLowerInvariant()
$via = "net.tcp://$viaHost/ASBService/$mxDataProvider/IDataV2"
Set-LiveEnvVar -Name 'MX_ASB_VIA' -Value $via
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