Commit Graph

13 Commits

Author SHA1 Message Date
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 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 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 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 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 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
Joseph Doherty fe2a6db786 Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:21:00 -04:00