Files
mxaccess/design/40-protocol-invariants.md
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
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

26 KiB
Raw Blame History

Protocol invariants — bill of materials

This is the wire-level spec the Rust port must hit byte-for-byte. Every entry cites its evidence in src/, docs/, analysis/, or captures/.

COM identifiers

Name GUID Source
NmxServiceClass (CLSID) AE24BD51-2E80-44CC-905B-E5446C942BEB src/MxNativeClient/NmxComContracts.cs:7
INmxService (IID) 575008DB-845D-46C6-A906-F6F8CA86F315 src/MxNativeClient/NmxComContracts.cs:24
INmxService2 (IID) 2630A513-A974-4B1A-8025-457A9A7C56B8 src/MxNativeClient/NmxComContracts.cs:51
INmxSvcCallback (IID) B49F92F7-C748-4169-8ECA-A0670B012746 src/MxNativeClient/NmxComContracts.cs:84
DCE/RPC bind context UUID (initial bind, opnum 0 calls) 4e0c90df-e39d-4164-a421-ace89484c602 docs/Loopback-Protocol-Findings.md:63
DCE/RPC service UUID (altered context, main opnums 0/2/3/5) 1981974b-6bf7-46cb-9640-0260bbb551ba docs/Loopback-Protocol-Findings.md:64
Standard NDR transfer syntax v2.0 8a885d04-1ceb-11c9-9fe8-08002b104860 [MS-RPCE] §14.3

INmxService2 opnums (after IUnknown's 0/1/2)

INmxService2 inherits from INmxService. Opnums are sequential across the inheritance.

In the IDL/COM proxy these opnums are sequential because INmxService2 extends INmxService and the derived interface continues the same vtable. In the .NET interop interface (src/MxNativeClient/NmxComContracts.cs:5080) the methods are re-declared with the new modifier (new void RegisterEngine(...), new void UnRegisterEngine(...), etc.) so the managed INmxService2 carries its own vtable slots distinct from the base interface — that managed shadowing is a C# interop detail and does not affect the wire opnum table. The Rust port targets the IDL/wire opnums (3..11 below), not the .NET interop vtable.

Opnum Method Inputs Outputs
3 RegisterEngine engineId(i32), engineName(BSTR), callback(*INmxSvcCallback) hresult
4 UnRegisterEngine engineId(i32) hresult
5 Connect localEngineId(i32), remoteGalaxyId(i32), remotePlatformId(i32), remoteEngineId(i32) hresult
6 TransferData remoteGalaxyId(i32), remotePlatformId(i32), remoteEngineId(i32), size(i32), messageBody(byte[size]) hresult
7 AddSubscriberEngine localEngineId(i32), subscriberGalaxyId(i32), subscriberPlatformId(i32), subscriberEngineId(i32) hresult
8 RemoveSubscriberEngine same as Add hresult
9 SetHeartbeatSendInterval ticksPerBeat(i32), maxMissedTicks(i32) hresult
10 RegisterEngine2 engineId(i32), engineName(BSTR), version(i32), callback(*INmxSvcCallback) hresult
11 GetPartnerVersion galaxyId(i32), platformId(i32), engineId(i32) hresult, version(out i32)

Source: src/MxNativeClient/NmxComContracts.cs:1180.

INmxSvcCallback opnums

Opnum Method Inputs Outputs
3 DataReceived bufferSize(i32), dataBuffer(sbyte[bufferSize]) hresult
4 StatusReceived bufferSize(i32), statusBuffer(sbyte[bufferSize]) hresult

Source: src/MxNativeClient/NmxComContracts.cs:8592. Method names match the MIDL signatures at src/MxNativeClient/NmxSvcCallbackMessages.cs:11-12 and src/MxNativeClient/NmxProcedureMetadata.cs:89-101 exactly — the Raw suffix used in earlier drafts was doc-invented and has been removed.

Network ports

Endpoint Port Notes
IObjectExporter (RPCSS endpoint mapper) 135/tcp DCE/RPC over TCP
NmxSvc.exe static endpoint registration 5026/tcp+udp Registered with RPCSS; actual listening socket resolved via OXID at runtime (observed dynamic ports e.g. 49704).
Callback server (mxaccess-callback) ephemeral Embedded in OBJREF dual-string
ASB endpoint configurable, typical net.tcp://host:5021/... Read from HKLM\ArchestrA\ArchestrAServices\{Solution}

NMX TransferData envelope (46 bytes)

Offset Size Field Encoding
0 2 Version u16 LE = 1
2 4 InnerLength i32 LE = body.len() - 46
6 4 Reserved 4 bytes. Not preserved by the .NET reference: Parse skips bytes 6..10 and Encode always writes 0 there (src/MxNativeCodec/NmxTransferEnvelope.cs:3975, 91). The Rust port intentionally adds preservation by carrying these four bytes as reserved6_10: [u8; 4] through parse/encode (default [0; 4] for new envelopes), per the 10-raw-layer.md envelope section — this fixes a CLAUDE.md "preserve unknown bytes" gap that the .NET reference does not.
10 4 MessageKind i32 LE: 1=Metadata, 2=ItemControl, 3=Write
14 4 SourceGalaxyId i32 LE
18 4 SourcePlatformId i32 LE
22 4 LocalEngineId i32 LE
26 4 TargetGalaxyId i32 LE
30 4 TargetPlatformId i32 LE
34 4 TargetEngineId i32 LE
38 4 ProtocolMarker i32 LE = 0x0000_0201; on-wire byte sequence 01 02 00 00 (low byte first). Encoded by BinaryPrimitives.WriteInt32LittleEndian(... ProtocolMarker) (src/MxNativeCodec/NmxTransferEnvelope.cs:99).
42 4 TimeoutMilliseconds i32 LE (default 30000)

Source: src/MxNativeCodec/NmxTransferEnvelope.cs:5104.

InnerLength must match actual body size. The native adapter logs NMX Header ... buffer size pktHeader.dwDataSize N doesn't match received message size of 46 (work_remain.md:7485) when it does not. The encoder validates the relationship before transmitting; envelope-only sends with InnerLength == 0 are rejected unless explicitly opted into for diagnostics.

MxReferenceHandle (20 bytes)

Offset Size Field Encoding
0 1 GalaxyId u8
1 1 Reserved u8 = 0
2 2 PlatformId u16 LE
4 2 EngineId u16 LE
6 2 ObjectId u16 LE
8 2 ObjectSignature u16 LE = CRC-16/IBM(lowercase UTF-16LE objectTagName)
10 2 PrimitiveId i16 LE
12 2 AttributeId i16 LE
14 2 PropertyId i16 LE
16 2 AttributeSignature u16 LE = CRC-16/IBM(lowercase UTF-16LE attributeName)
18 2 AttributeIndex i16 LE = -1 if array, 0 otherwise

CRC-16/IBM polynomial 0xa001 (right-shifted variant). Initial value 0 (src/MxNativeCodec/MxReferenceHandle.cs:51 — literal ushort crc = 0, not 0xFFFF). For each char of name.ToLowerInvariant(), apply the low byte then the high byte of the UTF-16LE representation (src/MxNativeCodec/MxReferenceHandle.cs:5256).

Source: src/MxNativeCodec/MxReferenceHandle.cs:5120. Per-char loop at MxReferenceHandle.cs:4759; inner CRC byte step at MxReferenceHandle.cs:108119.

Item-control bodies

Command Opcode Length
AdviseSupervisory 0x1f 39 bytes (HeaderLength 3 + GUID 16 + AdviseExtra 2 + Payload 18)
UnAdvise 0x21 37 bytes (HeaderLength 3 + GUID 16 + Payload 18)

The enum defines Advise = 0x1f and AdviseSupervisory = 0x1f as the same opcode (src/MxNativeCodec/NmxItemControlMessage.cs:78), but NmxItemControlMessage.Parse only accepts AdviseSupervisory or UnAdvise and rejects any other command byte (src/MxNativeCodec/NmxItemControlMessage.cs:4649). A 37-byte 0x1f "plain advise" body is not a wire shape the codec produces or accepts. The compatibility layer's AdviseSupervisory method forwards to Advise, both encoded as the 39-byte AdviseSupervisory body (src/MxNativeClient/MxNativeCompatibilityServer.cs:256258).

AdviseSupervisory layout: cmd(1) + version u16(2) + correlation(GUID 16) + adviseExtra(2) + handle_projection(14, bytes 6..19 of MxReferenceHandle) + tail u32(4) (src/MxNativeCodec/NmxItemControlMessage.cs:2535,121142). The 2-byte adviseExtra is omitted for UnAdvise.

tail constant value: 3 (u32 LE = 03 00 00 00). NmxItemControlMessage.FromReferenceHandle defaults the parameter to tail = 3 (src/MxNativeCodec/NmxItemControlMessage.cs:88) and every call site in the .NET reference relies on that default. The Rust port must emit the literal value 3 for both AdviseSupervisory and UnAdvise; emitting any other value will be rejected by the responding NMX.

Source: src/MxNativeCodec/NmxItemControlMessage.cs:5154.

Write bodies (0x37, normal)

Common prefix (18 bytes): cmd(1=0x37) + version u16(2=1) + handle_projection(14) + wireKind(1) (src/MxNativeCodec/NmxWriteMessage.cs:1113,207213). HandleProjectionOffset = 3, HandleProjectionLength = 14, KindOffset = 17. There is no padding between version and the handle projection; the handle projection is bytes 6..19 of the 20-byte MxReferenceHandle written directly at offset 3.

Normal scalar suffix (14 bytes + writeIndex): [-1 i16] + filler(8 zero bytes) + clientToken u32(4) + writeIndex i32(4) (src/MxNativeCodec/NmxWriteMessage.cs:215226).

Boolean has its own 11-byte suffix instead: 7 zero bytes + clientToken u32(4) + writeIndex i32(4) (src/MxNativeCodec/NmxWriteMessage.cs:228238).

WireKind Type Value section Total
0x01 Boolean 4 bytes literal [0xff,0xff,0xff,0x00] (true) or [0x00,0xff,0xff,0x00] (false). Bytes 1 and 2 are 0xFF filler, NOT reserved zeros (src/MxNativeCodec/NmxWriteMessage.cs:257). 37 (KindOffset(17) + 1 + 4 + 11 + 4, src/MxNativeCodec/NmxWriteMessage.cs:121128)
0x02 Int32 4 bytes LE 40
0x03 Float32 4 bytes IEEE 40
0x04 Float64 8 bytes IEEE 44
0x05 String recordLength i32(4) + valueByteLength i32(4) + UTF-16LE bytes(N) + null(2) 44 + N (KindOffset(17) + 1 + 4 + 4 + N + 14 + 4, src/MxNativeCodec/NmxWriteMessage.cs:148157)
0x05 DateTime Same shape as String; value is UTF-16LE of DateTime.ToString("M/d/yyyy h:mm:ss tt", InvariantCulture) + null (src/MxNativeCodec/NmxWriteMessage.cs:262,390393) 44 + N
0x41 BoolArray 4 unused bytes + count u16 at body[22] + elementWidth u16 at body[24] + elements at body[28] 18 (prefix) + 10 (array header) + 2N + 14-byte suffix + 4 writeIndex

BoolArray element encoding (writes and reads agree): each element is a little-endian i16, not a single byte. true-1[0xFF, 0xFF]; false0[0x00, 0x00]. Encoder: BinaryPrimitives.WriteInt16LittleEndian(..., values[i] ? (short)-1 : (short)0) (src/MxNativeCodec/NmxWriteMessage.cs:307). Decoder: BinaryPrimitives.ReadInt16LittleEndian(...) != 0 (src/MxNativeCodec/NmxSubscriptionMessage.cs:282, 290). Element width is therefore 2 bytes (the 2N in the BoolArray total), and the array-header elementWidth field carries 2. | 0x42 | Int32Array | same shape, 4-byte elements | 18 + 10 + 4N + 14 + 4 | | 0x43 | Float32Array | same | 18 + 10 + 4N + 14 + 4 | | 0x44 | Float64Array | same, 8-byte elements | 18 + 10 + 8N + 14 + 4 | | 0x45 | StringArray / DateTimeArray | per-element length-prefixed records (src/MxNativeCodec/NmxWriteMessage.cs:346362) | 18 + 10 + Σ(per-element) + 14 + 4 |

Encoder vs decoder asymmetry for array headers (preserve verbatim): The encoder writes count as u16 at body[22] and elementWidth as u16 at body[24] — both 2-byte little-endian values (src/MxNativeCodec/NmxWriteMessage.cs:181182). The subscription/callback decoder, however, reads count as u16 at body+4 and elementWidth as i32 at body+6 — a 4-byte little-endian read (src/MxNativeCodec/NmxSubscriptionMessage.cs:264265). Because the high u16 of the encoder's [count, elementWidth] slot is the small elementWidth value and the bytes at offsets 26..27 are zero (no other writes target that slot — NmxWriteMessage.cs:170186), an i32 read at body+6 of a captured write body sees the same numeric value. A Rust port must replicate the asymmetry exactly: write u16/u16, read u16+i32, do not normalize either side.

Source: src/MxNativeCodec/NmxWriteMessage.cs:7394. Per-type matrices: analysis/frida/write-body-matrix.tsv, write-array-body-matrix.tsv, write-mode-matrix.tsv.

Write2 (timestamped)

Same as Write but the 18-byte trailer (14-byte suffix + 4-byte writeIndex) is rewritten by WriteTimestampedSuffix (src/MxNativeCodec/NmxWriteMessage.cs:240251). The trailer is not lengthened — bytes are repacked, not inserted:

Trailer offset Size Normal Write Write2 (timestamped)
0 2 -1 i16 (NmxWriteMessage.cs:222) 0 i16 (NmxWriteMessage.cs:247)
2 8 8 zero filler bytes (NmxWriteMessage.cs:223225, WriteNormalSuffix zero-init) 8-byte FILETIME from timestamp.ToFileTime() (NmxWriteMessage.cs:248)
10 4 clientToken u32 (NmxWriteMessage.cs:225) clientToken u32 (NmxWriteMessage.cs:249)
14 4 writeIndex i32 (NmxWriteMessage.cs:226) writeIndex i32 (NmxWriteMessage.cs:250)

The FILETIME replaces the eight-byte filler that WriteNormalSuffix would otherwise leave zero; nothing is inserted between offsets 12 and 19. Total body size is identical to the corresponding non-timestamped Write.

Source: src/MxNativeCodec/NmxWriteMessage.cs:240251 (WriteTimestampedSuffix); compare to WriteNormalSuffix at src/MxNativeCodec/NmxWriteMessage.cs:215226.

WriteSecured2 (0x38)

Write2 body (without trailing clientToken+writeIndex), then:

currentUserToken(16) + clientNameLen(i32) + clientNameBytes(UTF-16LE+null)
                    + verifierUserToken(16) + (-1 i16) + clientToken(u32) + writeIndex(i32)

Observed authenticated user token (sample): 07 b9 a9 f4 72 6e ae 48 83 b5 bb de 91 8c 89 0f (captures/036-frida-secured*).

Source: src/MxNativeCodec/NmxSecuredWrite2Message.cs:6105.

SubscriptionStatus (0x32)

cmd(1=0x32) + version(2=1) + recordCount(i32) + operationId(GUID 16)
            + correlationId(GUID 16) + records[recordCount]
record: status(i32) + detailStatus(i32) + quality(u16)
      + timestamp_filetime(i64) + wireKind(u8) + value(N)

Header length: 39 bytes = cmd(1) + version(2) + recordCount(4) + operationId(16) + correlationId(16). Records start at byte offset 39 (src/MxNativeCodec/NmxSubscriptionMessage.cs:9899, for (int i = 0; i < recordCount; i++) over offset = 39).

DataUpdate (0x33)

cmd(1=0x33) + version(2=1) + recordCount(i32) + operationId(GUID 16)
            + records[recordCount]
record: status(i32) + quality(u16) + timestamp_filetime(i64) + wireKind(u8) + value(N)

Header length: 23 bytes = cmd(1) + version(2) + recordCount(4) + operationId(16). There is no per-message correlationId on 0x33; the record starts at byte offset 23 (src/MxNativeCodec/NmxSubscriptionMessage.cs:5455, 76recordCount read at offset 3, operationId at offset 7, recordOffset = 23). The 16-byte difference between the two header lengths (39 23) is exactly the 0x32-only correlationId slot.

Hard invariant: recordCount == 1 for 0x33 DataUpdate. The .NET parser throws ArgumentException on any other value (src/MxNativeCodec/NmxSubscriptionMessage.cs:7174if (recordCount != 1) throw). The Rust port replicates this as a typed error rather than degrading to opaque-bytes preservation, matching the executable spec. Multi-sample buffered batches are tracked as not-yet-wire-proven in 70-risks-and-open-questions.md (R2/R13); only single-record DataUpdate frames have been observed in captures/.

Wire kinds 0x01..0x07 (scalars) and 0x41..0x46 (arrays). The set is asymmetric across encode/decode: the write-side encoder collapses both StringArray and DateTimeArray to 0x45 and never emits 0x46 (src/MxNativeCodec/NmxWriteMessage.cs:107); the subscription/callback decoder accepts and demuxes 0x46 as DateTimeArray (src/MxNativeCodec/NmxSubscriptionMessage.cs:173,275). Writes use 0x41..0x45 only; reads/callbacks accept 0x41..0x46.

Source: src/MxNativeCodec/NmxSubscriptionMessage.cs:5428.

Reference registration (0x10 / 0x11)

Request (0x10) — fixed 55-byte header, then variable strings, then 20-byte tail. HeaderLength = 55 (src/MxNativeCodec/NmxReferenceRegistrationMessage.cs:15). The codec only writes at six explicit offsets inside the header (NmxReferenceRegistrationMessage.cs:8087); all other bytes within [0..55) are left as zero from the freshly-allocated buffer (NmxReferenceRegistrationMessage.cs:7178) and are preserved verbatim per CLAUDE.md unknown-bytes rule.

Offset Size Field Encoding / source
0 1 Command u8 = 0x10 (NmxReferenceRegistrationMessage.cs:80)
1 2 Version u16 LE = 1 (NmxReferenceRegistrationMessage.cs:81)
3 4 ItemHandle i32 LE (NmxReferenceRegistrationMessage.cs:82)
7 16 ItemCorrelationId GUID bytes (NmxReferenceRegistrationMessage.cs:83)
23 2 -1 i16 marker i16 LE = -1 (NmxReferenceRegistrationMessage.cs:85)
25 2 Reserved gap preserved as 0 — never written explicitly; zero-init from new byte[] (NmxReferenceRegistrationMessage.cs:7178). Preserved verbatim per CLAUDE.md unknown-bytes rule.
27 4 Constant 1 i32 i32 LE = 1 (NmxReferenceRegistrationMessage.cs:86)
31 24 Reserved gap preserved as 0 — never written explicitly; zero-init from new byte[] (NmxReferenceRegistrationMessage.cs:7178). The Parse method reads these bytes implicitly only via body.Slice(offset, ItemStringReservedLength) further on; the prefix range itself is round-tripped untouched. Preserved verbatim per CLAUDE.md unknown-bytes rule.

After the 55-byte prefix:

itemDefinition(taggedString: i32 length with high byte 0x81 + UTF-16LE + null)
itemStringReserved(8 bytes; Parse asserts all-zero, NmxReferenceRegistrationMessage.cs:4247)
itemContext(untaggedString: i32 length + UTF-16LE + null)
tail(20 bytes; first 19 zero, tail[19] = subscribe_flag, NmxReferenceRegistrationMessage.cs:5456,92)

Result (0x11):

cmd(1) + version(2) + itemHandle(i32) + correlation(GUID 16)
      + firstTimestamp(i64) + secondTimestamp(i64)
      + statusCategory(u8) + statusDetail(u8)
      + blockLength(i32)
      + itemDefinition(tagged) + mxDataType(i32) + reserved(6)
      + itemContext(untagged)
      + tail(16 zero)

Tail = 16 zero bytes is a hard parser invariant. NmxReferenceRegistrationResultMessage.Parse asserts both that the trailing slice is exactly TailLength = 16 (src/MxNativeCodec/NmxReferenceRegistrationResultMessage.cs:21, 5962) and that every byte in it is 0body.Slice(offset, TailLength).IndexOfAnyExcept((byte)0) >= 0 throws ArgumentException (NmxReferenceRegistrationResultMessage.cs:6467). Wire-confirmed against the 0x11 registration-result frames captured under captures/080-frida-buffered-external-write-testint/ (per docs/Capture-Run-2026-04-25.md:480486, which records that this capture supplied the stable normal-and-buffered 0x10 registration bodies plus the matching 0x11 registration-result frames used by the NmxReferenceRegistrationResultMessage tests — the same parser whose all-zero-tail assertion would have rejected those captures had the wire bytes been non-zero). Per CLAUDE.md's preserve-unknown-bytes rule the Rust port still carries them as 16 explicit bytes (not skipped) so non-zero tails surface as a typed parse error rather than silent acceptance.

Tagged-string encoding: 4-byte length: tagged ? (byteLength | 0x81000000) : byteLength followed by UTF-16LE bytes + null terminator.

Source: src/MxNativeCodec/NmxReferenceRegistrationMessage.cs:6142, NmxReferenceRegistrationResultMessage.cs:6120.

ASB Variant

Offset Size Field
0 2 Type ID (u16 LE, AsbDataType)
2 4 Length (i32 LE, logical)
6 4 PayloadLength (i32 LE, byte count)
10 N Payload

AsbDataType IDs (live-proven): Bool=17, Int32=4, Float=8, Double=9, String=10 (UTF-16LE), DateTime=11 (FILETIME), Duration=12 (.NET Ticks), Int32Array=44, FloatArray=48, DoubleArray=49, StringArray=50, DateTimeArray=51, DurationArray=52, BoolArray=57.

Source: src/MxAsbClient/AsbContracts.cs:11691293, docs/ASB-Variant-Wire-Format.md.

ASB AsbStatus

Offset Size Field
0 1 Count (sbyte: -1 marker-only, 0..N elements)
1 4 PayloadLength (u32 LE)
5 N Payload (packed status elements)

Status element: marker byte (high bit clear = value follows), low 7 bits = type ID. Known IDs: 5=MxStatusCategory, 6=MxStatusDetail, 7=MxQuality. If value present, 2-byte u16 LE follows.

Quality bits (mask 0x00C0): 0xC0=Good, 0x40=Uncertain, 0x00=Bad.

Source: src/MxAsbClient/AsbContracts.cs:11061167, src/MxAsbClient/AsbPublishedValue.cs:87119.

Authentication

NTLMv2 (NMX/DCE-RPC)

  • Negotiate flags: Unicode | RequestTarget | Sign | Seal | ExtendedSessionSecurity | Negotiate128 | KeyExchange
  • NTLMv2 NT-OWF = HMAC-MD5(MD4(unicode(password)), unicode(uppercase(user) + domain))
  • Type3 with AV pairs from server's TargetInfo (channel binding optional)
  • Packet integrity: HMAC-MD5(SignKey, sequence || plaintext) → first 8 bytes XOR with RC4 keystream
  • Sign-key / seal-key: MD5 over magic constants

Source: src/MxNativeClient/ManagedNtlmClientContext.cs:1389. Reference: [MS-NLMP].

ASB application-level

  1. DH key exchange (prime, generator, key size in HKLM\ArchestrA\ArchestrAServices\{Solution}).
  2. Shared secret = DH(remote_pub, local_priv, prime).
  3. AES key = SHA1(shared_secret || passphrase).
  4. Per-message HMAC (algorithm in registry: MD5/SHA1/SHA512).
  5. AES-128 message encryption with PBKDF2(passphrase, salt, 1000 iters, SHA1).

Passphrase obtained via DPAPI:

  • HKLM\[Wow6432Node\]ArchestrA\ArchestrAServices\{SolutionName}\sharedsecret
  • ProtectedData.Unprotect(bytes, Unicode("wonderware"), LocalMachine)

Source: src/MxAsbClient/AsbSystemAuthenticator.cs:8167, AsbRegistry.cs:867.

Galaxy SQL schema (subset)

Table Columns of interest
dbo.gobject tag_name, gobject_id, package_id
dbo.instance mx_platform_id, mx_object_id, mx_engine_id
dbo.dynamic_attribute mx_data_type, is_array
dbo.package inheritance chain (recursive CTE)
dbo.user_profile user_guid, user_profile_name, intouch_access_level, roles

GalaxyRepositoryTagResolver.cs:209293 is the canonical query (recursive CTE for deployed_package_chainranked_dynamicprimitive_attributes).

User-role parsing: hex-encoded UTF-16LE blob, scan for null-terminated wide-char strings (GalaxyRepositoryUserResolver.cs:87133).

HRESULT / status codes (observed)

HRESULT Meaning Source
0x00000000 S_OK trivially
0x00000001 S_FALSE (pending/retry) observed in callbacks
0x80004021 Returned by MxNativeSession.WriteSecuredAsync (the .NET native reimplementation) before reaching the wire — src/MxNativeClient/MxNativeSession.cs:218-221. NOT a real LMX-proxy constraint: wwtools/mxaccesscli/ verifies the production LMX WriteSecured always takes two user ids (currentUserId, verifierUserId, value) and accepts single-user secured writes as currentUserId == verifierUserId. See R6 in 70-risks-and-open-questions.md. docs/DotNet10-Native-Library-Plan.md; wwtools/mxaccesscli/docs/api-notes.md:60-72
0x80070057 E_INVALIDARG observed for stale handles
0x8007139F ERROR_INVALID_STATE — observed from IDataConsumer.ProcessActivateSuspend2 while the namespace is not yet activated. Despite the canonical Win32 name, the codebase elsewhere has labelled this "uninitialized object" / EngineNotRegistered; the canonical Win32 mapping is ERROR_INVALID_STATE per docs/Capture-Run-2026-04-25.md:888 and docs/MXAccess-Public-API.md:326. docs/Capture-Run-2026-04-25.md:872,886,888; docs/MXAccess-Public-API.md:313,326; docs/Current-Sprint-State.md:119,122
0x80040154 REGDB_E_CLASSNOTREG callback proxy/stub missing
0x8001011D ORPC callback OBJREF rejected (security binding) observed in callback flows
0x800706BA RPC server unavailable NmxSvc not running

Status detail codes (subset)

From MxStatusSource.RespondingNmx-side callbacks:

  • 16 = timeout
  • 17 = platform-comm failure
  • 18 = invalid platform id
  • 21 = invalid reference
  • 22 = no Galaxy Repository
  • 23 = invalid object id
  • 30 = type mismatch
  • 31 = not readable
  • 32 = not writeable
  • 33 = access denied
  • 56 = secured-write related
  • 57 = verified-write related

Three different on-wire widths carry "detail"-shaped numbers; do not conflate them.

Field Width Signedness Where it lives Source
MxStatus.Detail 2 bytes i16 (signed short) MxStatus record, the canonical promoted-status type src/MxNativeCodec/MxStatus.cs:32 (short Detail)
DataUpdate / SubscriptionStatus record quality 2 bytes u16 (unsigned, bitmask, mask 0x00C0) per-record header in 0x32/0x33 frames src/MxNativeCodec/NmxSubscriptionMessage.cs:136
Record status and (SubscriptionStatus only) detailStatus 4 bytes each i32 (signed) per-record header in 0x32/0x33 frames src/MxNativeCodec/NmxSubscriptionMessage.cs:126 (status), :132 (detailStatus)

When promoting a record to an MxStatus, the i32 detailStatus is narrowed to i16 — sign-extension applies on the way out, but values outside i16 range are not representable. The Rust port must preserve the on-wire width on each layer (don't widen everything to i32 on parse) so that an out-of-range detailStatus is a typed error rather than a silent truncation.

Source: docs/MXAccess-Public-API.md, the MxStatus parsing in src/MxNativeCodec/MxStatus.cs:3126, and the per-record decoder in src/MxNativeCodec/NmxSubscriptionMessage.cs:117149.

Completion-only frames

5-byte completion frame 00 00 50 80 00MxStatus.WriteCompleteOk (the only proven mapping).

1-byte completion frames (0x00, 0x41, 0xEF) are preserved as raw, unpromoted statuses (work_remain.md:164174). Do not synthesise typed completion events from them.

Items not yet wire-proven

  • Multi-sample buffered batches (recordCount > 1 in 0x33 frames). Provider does not currently emit them.
  • Generic OperationComplete events outside the proven 5-byte completion frame.
  • Activate/Suspend transition events.
  • ASB write timestamp + status fields in publish responses.

Listed with mitigation strategy in 70-risks-and-open-questions.md. The codec must accept these as opaque bytes and not promote them.