fe2a6db786
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>
442 lines
27 KiB
Markdown
442 lines
27 KiB
Markdown
# Raw layer
|
||
|
||
The raw layer is the byte-accurate Rust reimplementation of MXAccess. It lives in three sub-layers:
|
||
|
||
```
|
||
mxaccess-codec pure encoding/decoding, no I/O
|
||
|
|
||
v
|
||
mxaccess-rpc DCE/RPC + NTLMv2 + OBJREF + OXID resolution
|
||
mxaccess-callback INmxSvcCallback RPC server (callback exporter)
|
||
|
|
||
v
|
||
mxaccess-nmx INmxService2 client + Galaxy SQL resolver
|
||
mxaccess-asb IASBIDataV2 client (alternate data plane)
|
||
```
|
||
|
||
Codec is pure and runtime-agnostic. Transport crates use Tokio for I/O. Neither layer exposes Tokio in the public types except through `async fn` signatures.
|
||
|
||
## `mxaccess-codec`
|
||
|
||
Pure protocol codec. No I/O. Compiles on every Rust target including non-Windows. Allocations only where the protocol mandates variable-length fields (string values, array payloads, registration bodies).
|
||
|
||
### `MxReferenceHandle` (20 bytes)
|
||
|
||
Source: `src/MxNativeCodec/MxReferenceHandle.cs:5–120`.
|
||
|
||
```rust
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct MxReferenceHandle {
|
||
pub galaxy_id: u8,
|
||
// Byte 1 is reserved (always 0). Not exposed publicly.
|
||
pub platform_id: u16,
|
||
pub engine_id: u16,
|
||
pub object_id: u16,
|
||
object_signature: u16, // private — CRC-16/IBM of lowercase UTF-16LE object tag
|
||
pub primitive_id: i16,
|
||
pub attribute_id: i16,
|
||
pub property_id: i16,
|
||
attribute_signature: u16, // private — CRC-16/IBM of lowercase UTF-16LE attribute name
|
||
pub attribute_index: i16, // -1 array, 0 scalar
|
||
}
|
||
|
||
impl MxReferenceHandle {
|
||
pub const ENCODED_LEN: usize = 20;
|
||
|
||
/// The only constructor that derives signatures from names. Recomputes both
|
||
/// `object_signature` and `attribute_signature` so they cannot desync from
|
||
/// the names that produced them.
|
||
pub fn from_names(
|
||
galaxy_id: u8,
|
||
platform_id: u16,
|
||
engine_id: u16,
|
||
object_id: u16,
|
||
object_tag_name: &str,
|
||
primitive_id: i16,
|
||
attribute_id: i16,
|
||
property_id: i16,
|
||
attribute_name: &str,
|
||
is_array: bool,
|
||
) -> Self;
|
||
|
||
/// Parse from a captured 20-byte handle. Signatures come straight from the
|
||
/// wire; the caller is asserting the bytes are authoritative.
|
||
pub fn parse(bytes: &[u8; Self::ENCODED_LEN]) -> Self;
|
||
pub fn encode(self, dst: &mut [u8; Self::ENCODED_LEN]);
|
||
|
||
/// Read-only accessors — there is intentionally no `set_*_signature`.
|
||
pub fn object_signature(self) -> u16 { self.object_signature }
|
||
pub fn attribute_signature(self) -> u16 { self.attribute_signature }
|
||
|
||
/// Returns a new handle with `attribute_name`'s signature recomputed,
|
||
/// preserving every other field. Use this instead of mutating in place.
|
||
pub fn with_attribute_name(self, attribute_name: &str) -> Self;
|
||
pub fn with_object_tag_name(self, object_tag_name: &str) -> Self;
|
||
|
||
pub fn compute_name_signature(name: &str) -> u16;
|
||
}
|
||
```
|
||
|
||
`object_signature` and `attribute_signature` are **derived** values and are the only fields not exposed `pub`. There is no setter that takes a raw `u16` signature without a corresponding name — the only way to update a signature is to hand a name in, which forces a recomputation. This keeps `(object_tag_name, object_signature)` and `(attribute_name, attribute_signature)` consistent by construction. (The .NET reference is more permissive — `MxReferenceHandle` is a record with public init-only signature fields — but mirroring that in Rust would invite caller bugs that the wire format silently rejects with `0x80070057`.)
|
||
|
||
`compute_name_signature` mirrors the .NET `ComputeNameSignature` exactly. For each `char` in `name.to_lowercase()`, run the byte sequence in UTF-16LE order through `update_crc16_ibm` (poly `0xa001`, initial `0`, low byte first then high byte).
|
||
|
||
⚠ **Unicode lowercasing must match `String.ToLowerInvariant()` semantics, NOT `str::to_lowercase()`.** The .NET `ToLowerInvariant()` uses the culture-invariant Unicode case map (`CultureInfo.InvariantCulture.TextInfo.ToLower`, ICU-derived). Rust `str::to_lowercase()` is **locale-dependent** in spirit (it follows Unicode's special-casing rules, e.g. Turkish dotless-i is mapped per the Default Casing algorithm with no tailoring, which is *close to* but not identical to `Invariant`). Worse, `unicase::Ascii::to_lowercase` is **ASCII-only** and silently passes non-ASCII characters through unchanged — for any tag containing a non-ASCII character it will produce a CRC that disagrees with the .NET reference. The Rust port should:
|
||
1. Use `icu_casemap::CaseMapper::lowercase_to_string` (ICU4X) configured for `Locale::UND` / Root locale to match `Invariant`, **or** hand-implement Unicode `Default_Lowercase` via the UCD `SpecialCasing.txt` and `UnicodeData.txt` `Lowercase_Mapping` field with no language tailoring.
|
||
2. **Not** use `str::to_lowercase()` (locale-flavored) and **not** use `unicase::Ascii::to_lowercase` (ASCII-only — destroys non-ASCII parity).
|
||
3. Treat this as a divergence-test requirement: `mxaccess-codec/tests/` must include fixture tag names containing characters whose ASCII-vs-invariant mapping differs (Turkish dotted-İ → `i̇`, German ß → `ss` is *not* applied by `ToLowerInvariant` so `ß → ß`, Greek Σ → σ — confirm against `MxReferenceHandle.cs:47–59` outputs from the .NET reference and assert byte-equal CRC).
|
||
|
||
### `NmxTransferEnvelope` (46 bytes)
|
||
|
||
Source: `src/MxNativeCodec/NmxTransferEnvelope.cs:5–104`.
|
||
|
||
```rust
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct NmxTransferEnvelope {
|
||
pub version: u16, // 1
|
||
pub inner_length: i32, // body.len() - 46
|
||
pub reserved6_10: [u8; 4], // bytes 6..10; preserved verbatim by Rust port — see note below
|
||
pub message_kind: NmxTransferMessageKind, // 1=Metadata, 2=ItemControl, 3=Write
|
||
pub source_galaxy_id: i32,
|
||
pub source_platform_id: i32,
|
||
pub local_engine_id: i32,
|
||
pub target_galaxy_id: i32,
|
||
pub target_platform_id: i32,
|
||
pub target_engine_id: i32,
|
||
pub protocol_marker: i32, // 0x0201
|
||
pub timeout_ms: i32, // default 30000
|
||
}
|
||
```
|
||
|
||
`NmxTransferEnvelopeTemplate` is the round-trip preserver: takes a captured 46-byte buffer, exposes setters that patch only `inner_length`, leaves every other byte untouched. Used for `ObservedWriteBodyTemplate`. **This is non-optional** — the protocol has bytes whose semantics are not yet decoded; the .NET reference passes them through.
|
||
|
||
⚠ **Reserved bytes 6..10 are NOT preserved by the .NET reference parser.** `NmxTransferEnvelope.Parse` reads only Version, InnerLength, ProtocolMarker, MessageKind, and the engine ids; the four bytes at offset 6 are discarded (`src/MxNativeCodec/NmxTransferEnvelope.cs:39–75`), and `Encode` always writes `0` there (`src/MxNativeCodec/NmxTransferEnvelope.cs:91`). The Rust port intentionally **fixes this gap** by carrying `reserved6_10: [u8; 4]` through `parse`/`encode`, defaulting to `[0; 4]` for newly-constructed envelopes. This honours CLAUDE.md's preserve-unknown-bytes rule on a layer the .NET reference does not — the Rust codec is closer to true byte-parity than the .NET parser when round-tripping captured envelopes that have non-zero bytes at offset 6.
|
||
|
||
⚠ The native adapter logs `NMX Header ... buffer size pktHeader.dwDataSize N doesn't match received message size of 46` (`work_remain.md:74–85`) when `inner_length` does not match the actual body size. The encoder validates `inner_length == body.len() - 46` before transmitting.
|
||
|
||
### Item-control bodies (advise / supervisory / unadvise)
|
||
|
||
| 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 `Advise` enum value shares opcode `0x1f` with `AdviseSupervisory` (`src/MxNativeCodec/NmxItemControlMessage.cs:7–8`), but `NmxItemControlMessage.Parse` only accepts `AdviseSupervisory` or `UnAdvise` (`src/MxNativeCodec/NmxItemControlMessage.cs:46–49`). There is **no** separate 37-byte plain-Advise wire shape — the higher-level `MxNativeCompatibilityServer.AdviseSupervisory` simply forwards to `Advise`, both of which encode an `AdviseSupervisory` 39-byte body (`src/MxNativeClient/MxNativeCompatibilityServer.cs:256–258`).
|
||
|
||
Layout: `command(1) + version(2) + correlation_id(GUID 16) + [extra(2) when AdviseSupervisory] + handle_projection(14) + tail(4)` (`src/MxNativeCodec/NmxItemControlMessage.cs:25–35,121–142`).
|
||
|
||
Source: `src/MxNativeCodec/NmxItemControlMessage.cs:5–154`.
|
||
|
||
### Write bodies (`0x37` / `0x38`)
|
||
|
||
Common prefix (18 bytes) ends in a wire-kind byte at offset 17. Layout of the prefix is `cmd(1) + version u16(2) + handle_projection(14, bytes 6..19 of MxReferenceHandle) + wireKind(1)` (`src/MxNativeCodec/NmxWriteMessage.cs:11–13,207–213`). There is no padding between version and the handle projection.
|
||
|
||
| WireKind | Type | Total scalar size | Notes |
|
||
|---|---|---|---|
|
||
| `0x01` | Boolean | 37 | 4-byte value `[0xff,0xff,0xff,0x00]` (true) or `[0x00,0xff,0xff,0x00]` (false) — bytes 1 and 2 are literal `0xFF` filler, NOT reserved zeros (`src/MxNativeCodec/NmxWriteMessage.cs:257`); 11-byte boolean suffix (7 zero bytes + 4-byte clientToken, `NmxWriteMessage.cs:228–238`) + 4-byte writeIndex |
|
||
| `0x02` | Int32 | 40 | 4 LE + 14-byte suffix + 4 writeIndex |
|
||
| `0x03` | Float32 | 40 | 4 IEEE + 14 + 4 |
|
||
| `0x04` | Float64 | 44 | 8 IEEE + 14 + 4 |
|
||
| `0x05` | String | 44 + N | recordLength i32(4) + valueByteLength i32(4) + UTF-16LE bytes(N) + null(2) + 14-byte suffix + 4-byte writeIndex; total = `KindOffset(17) + 1 + 4 + 4 + N + 14 + 4` (`src/MxNativeCodec/NmxWriteMessage.cs:148–157`) |
|
||
| `0x05` | DateTime | 44 + N | 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,390–393`) |
|
||
| `0x41–0x45` | Arrays | 18 + 10 + N + 18 | prefix(18) + 4 unused bytes + count u16 at body[22] + elementWidth u16 at body[24] + elements(N) + 14-byte suffix + 4-byte writeIndex (`src/MxNativeCodec/NmxWriteMessage.cs:179–186`) |
|
||
|
||
`Write2` (timestamped) replaces the `-1 i16` flag with `0 i16` and inserts an 8-byte `FILETIME` (`DateTime.ToFileTime()`) between offsets 12 and 19 of the suffix.
|
||
|
||
`WriteSecured2` (`0x38`) appends `currentUserToken(16) + clientNameLen(i32) + clientNameBytes(UTF-16LE+null) + verifierUserToken(16)` before the trailing index slot.
|
||
|
||
Sources: `src/MxNativeCodec/NmxWriteMessage.cs:7–394`, `NmxSecuredWrite2Message.cs:6–105`. Per-type byte matrices in `analysis/frida/write-body-matrix.tsv`, `write-array-body-matrix.tsv`, `write-mode-matrix.tsv`.
|
||
|
||
`ObservedWriteBodyTemplate` mirrors the .NET helper: take a captured write body, replace only the value slot, preserve every other byte (suffix, tokens, padding).
|
||
|
||
### Subscription Status (`0x32`) and DataUpdate (`0x33`)
|
||
|
||
Source: `src/MxNativeCodec/NmxSubscriptionMessage.cs:5–428`.
|
||
|
||
**The two frames share a 23-byte common header (`cmd + version + recordCount + operationId`) but diverge immediately after.** The parser must dispatch on `cmd` before reading any further bytes; do not unify the two paths.
|
||
|
||
`SubscriptionStatus` (cmd `0x32`) — header → **per-message correlationId** → records (`src/MxNativeCodec/NmxSubscriptionMessage.cs:87–115`):
|
||
```
|
||
cmd(1=0x32) + version(2=1) + recordCount(i32) + operationId(GUID 16) [bytes 0..23]
|
||
correlationId(GUID 16) [bytes 23..39]
|
||
records[recordCount] [from byte 39]
|
||
record: status(i32) + detailStatus(i32) + quality(u16)
|
||
+ timestamp_filetime(i64) + wireKind(u8) + value(N)
|
||
```
|
||
|
||
`DataUpdate` (cmd `0x33`) — header → records, **no correlationId** (`src/MxNativeCodec/NmxSubscriptionMessage.cs:54–55, 65–85`):
|
||
```
|
||
cmd(1=0x33) + version(2=1) + recordCount(i32) + operationId(GUID 16) [bytes 0..23]
|
||
records[recordCount] [from byte 23]
|
||
record: status(i32) + quality(u16) + timestamp_filetime(i64)
|
||
+ wireKind(u8) + value(N)
|
||
```
|
||
|
||
⚠ **`recordCount != 1` is a hard error on `0x33` DataUpdate.** The .NET parser throws `ArgumentException` (`src/MxNativeCodec/NmxSubscriptionMessage.cs:71–74`). The Rust port replicates this as a typed error (do not silently accept multi-record DataUpdate frames); buffered batches are listed in `70-risks-and-open-questions.md` (R2/R13) as not yet wire-proven.
|
||
|
||
Wire kinds are 0x01..0x07 (scalars) and 0x41..0x46 (arrays).
|
||
|
||
ⓘ **Known gap — wire kind `0x47` (`ElapsedTimeArray`) is not enumerated by either the .NET reference or this design.** The scalar `ElapsedTime` (`0x07`, `src/MxNativeCodec/MxValueKind.cs`) has no array counterpart in either `MxValueKind` or `MxValue`, and neither `NmxWriteMessage.cs` (encoder) nor `NmxSubscriptionMessage.cs:270–276` (decoder) has a branch for `0x47`. If a future Frida capture exposes such a frame, both sides need an additive enum variant (`ElapsedTimeArray` → `0x47`) plus a value carrier; the current parser will fall through to the default `(wireKind, null, 0)` opaque arm and the encoder will simply have no way to emit it. Document as a known gap rather than silently extending the enum without evidence.
|
||
|
||
⚠ **Encoder/decoder asymmetry on the array kind byte (preserve verbatim):** the write encoder collapses both `StringArray` and `DateTimeArray` to `0x45` and never emits `0x46` (`src/MxNativeCodec/NmxWriteMessage.cs:107`). The subscription/callback decoder treats `0x46` as `DateTimeArray` (`src/MxNativeCodec/NmxSubscriptionMessage.cs:173,275`). Therefore: **writes use `0x41..0x45` only; reads accept `0x41..0x46`.** The Rust port's encoder must match (only emit up to `0x45`); the decoder must accept the full `0x41..0x46` range and demux `StringArray` vs `DateTimeArray` from `wireKind`, not from any encoder-side metadata.
|
||
|
||
### Reference registration (`0x10` / `0x11`)
|
||
|
||
Source: `src/MxNativeCodec/NmxReferenceRegistrationMessage.cs:6–142`, `NmxReferenceRegistrationResultMessage.cs:6–120`.
|
||
|
||
Tagged-string encoding: 4-byte length prefix where `tagged ? (byteLength | 0x81000000) : byteLength`, followed by UTF-16LE bytes plus a null terminator. Codec preserves the 8 zero bytes of `ItemStringReservedLength` (lines 42–45) and the `0x81000000` marker on tagged strings.
|
||
|
||
### Type model
|
||
|
||
Compatible with the .NET enums in `src/MxNativeCodec/MxStatus.cs`, `MxValueKind.cs`, `MxDataType.cs`.
|
||
|
||
```rust
|
||
#[repr(i16)]
|
||
pub enum MxStatusCategory {
|
||
Unknown = -1, Ok = 0, Pending = 1, Warning = 2, CommunicationError = 3,
|
||
ConfigurationError = 4, OperationalError = 5, SecurityError = 6,
|
||
SoftwareError = 7, OtherError = 8,
|
||
}
|
||
|
||
#[repr(i16)]
|
||
pub enum MxStatusSource {
|
||
Unknown = -1, RequestingLmx = 0, RespondingLmx = 1, RequestingNmx = 2,
|
||
RespondingNmx = 3, RequestingAutomationObject = 4, RespondingAutomationObject = 5,
|
||
}
|
||
|
||
pub struct MxStatus {
|
||
pub success: i16,
|
||
pub category: MxStatusCategory,
|
||
pub detected_by: MxStatusSource, // .NET field name is `DetectedBy` (`src/MxNativeCodec/MxStatus.cs:31`); not `Source`
|
||
pub detail: i16, // i16, signed; matches .NET `short Detail` (`src/MxNativeCodec/MxStatus.cs:32`)
|
||
}
|
||
|
||
pub enum MxValueKind {
|
||
Boolean, Int32, Float32, Float64, String, DateTime, ElapsedTime,
|
||
BooleanArray, Int32Array, Float32Array, Float64Array,
|
||
StringArray, DateTimeArray,
|
||
// No ElapsedTimeArray variant — see footnote.
|
||
}
|
||
|
||
#[repr(i16)]
|
||
pub enum MxDataType {
|
||
Unknown = -1, NoData = 0, Boolean = 1, Integer = 2, Float = 3, Double = 4,
|
||
String = 5, Time = 6, ElapsedTime = 7, ReferenceType = 8, StatusType = 9,
|
||
Enum = 10, SecurityClassificationEnum = 11, DataQualityType = 12,
|
||
QualifiedEnum = 13, QualifiedStruct = 14, InternationalizedString = 15,
|
||
BigString = 16, End = 17,
|
||
}
|
||
```
|
||
|
||
### `MxValue` — typed value carrier
|
||
|
||
```rust
|
||
pub enum MxValue {
|
||
Boolean(bool),
|
||
Int32(i32),
|
||
Float32(f32),
|
||
Float64(f64),
|
||
String(String),
|
||
DateTime(SystemTime), // FILETIME at codec boundary
|
||
ElapsedTime(MxElapsedTime), // signed wire — see note below
|
||
BooleanArray(Vec<bool>),
|
||
Int32Array(Vec<i32>),
|
||
Float32Array(Vec<f32>),
|
||
Float64Array(Vec<f64>),
|
||
StringArray(Vec<String>),
|
||
DateTimeArray(Vec<SystemTime>),
|
||
}
|
||
```
|
||
|
||
Conversions:
|
||
- `SystemTime` ↔ FILETIME: 100-ns intervals since `1601-01-01T00:00:00Z`. `time::OffsetDateTime` is also acceptable; pick one and stay consistent. The codec layer accepts both via traits.
|
||
- `ElapsedTime`: **4-byte signed `i32` milliseconds** on the wire. The .NET decoder reads `BinaryPrimitives.ReadInt32LittleEndian` and produces `TimeSpan.FromMilliseconds(milliseconds)` (`src/MxNativeCodec/NmxSubscriptionMessage.cs:252`), which accepts negative values. `std::time::Duration` is unsigned and **must not** be used here — it cannot represent a negative ms value and panics on conversion. The Rust port exposes a newtype `pub struct MxElapsedTime(pub i64);` (milliseconds, signed) — `i64` rather than `i32` so it can also carry the wider `time::Duration`/.NET `TimeSpan` range without precision loss when promoted at the async layer.
|
||
|
||
ASB variant lives in a parallel `mxaccess_codec::asb::AsbVariant` because it is wire-incompatible (different type-id space and binary layout).
|
||
|
||
### Preservation rules
|
||
|
||
Every codec type that decodes a message keeps a private `original: Bytes` field. `re_encode()` returns the original bytes when no fields were mutated. This guarantees byte parity on captured fixtures even when fields' meanings are not fully understood.
|
||
|
||
For mutable round-trips (e.g. re-encoding a captured `Write2` with a new value), only mutated fields are re-emitted; the rest of the buffer is patched in place. This matches `ObservedWriteBodyTemplate` in the .NET reference and is essential for the parity test strategy described in `60-roadmap.md`.
|
||
|
||
### Test strategy (codec)
|
||
|
||
- **Round-trip fixtures**: every captured write/advise/subscribe body in `captures/0NN-frida-*` and every row in `analysis/frida/*-matrix.tsv` is loaded, decoded, re-encoded, and asserted byte-equal.
|
||
- **Property tests**: `proptest` generators for each primitive (`MxReferenceHandle`, envelope, write body) — encode then decode, assert structural equality.
|
||
- **CRC vectors**: hardcoded vectors from .NET unit tests are mirrored as Rust constants and asserted.
|
||
- **Cross-implementation**: a small fixture runner shells out to `dotnet run --project src\MxNativeCodec.Tests` and asserts the same bytes are produced.
|
||
|
||
## `mxaccess-rpc`
|
||
|
||
DCE/RPC over TCP, NTLMv2 packet integrity, OXID resolution, OBJREF parsing, IRemUnknown::RemQueryInterface. The minimum subset of [MS-RPCE], [MS-DCOM], and [MS-NLMP] required to drive `INmxService2`.
|
||
|
||
### NTLM
|
||
|
||
Source: `src/MxNativeClient/ManagedNtlmClientContext.cs:1–389`. Implements:
|
||
|
||
- **Type1 (Negotiate)** — emit. Negotiate flags as set by `CreateType1` (`src/MxNativeClient/ManagedNtlmClientContext.cs:53–63`): `KeyExchange (0x40000000) | Sign (0x00000010) | AlwaysSign (0x00008000) | Seal (0x00000020) | TargetInfo (0x00800000) | Ntlm (0x00000200) | ExtendedSessionSecurity (0x00080000) | Unicode (0x00000001) | RequestTarget (0x00000004) | Negotiate128 (0x20000000) | Negotiate56 (0x80000000)`. Bit constants per `ManagedNtlmClientContext.cs:10–21`.
|
||
- **Type2 (Challenge)** — parse. Extract server challenge, AV pairs from TargetInfo (timestamp, channel binding optional).
|
||
- **Type3 (Authenticate)** — emit. NTLMv2 NT-OWF = HMAC-MD5(MD4(unicode(password)), unicode(uppercase(user) + domain)). Client challenge with AV pairs replayed from the Type2.
|
||
- **Sign / Verify** — packet-integrity signature: HMAC-MD5(SignKey, sequence || plaintext) → first 8 bytes XOR with RC4 keystream.
|
||
- **Seal / Unseal** — RC4 stream cipher with derived seal key.
|
||
- **Sign-key / seal-key** — derived via MD5 on a magic-constant string.
|
||
|
||
Rust crates: `hmac`, `md-5`, `rc4` (or hand-rolled), `rand_core`. **Do not pull `ring`** — it does not implement MD4. Hand-roll MD4 (~30 lines, mirroring the .NET reference).
|
||
|
||
### DCE/RPC PDU
|
||
|
||
Source: `src/MxNativeClient/DceRpcPdu.cs:1–380`, `DceRpcTcpClient.cs:1–420`.
|
||
|
||
PDU types implemented: `Bind` (11), `BindAck` (12), `Request` (0), `Response` (2), `Fault` (3), `AlterContext` (14), `AlterContextResponse` (15), `Auth3` (16). Authentication trailer: type=NTLMSSP (10), level=PKT_INTEGRITY (5).
|
||
|
||
Fragmentation: max transmit/receive 4280 bytes. Multi-fragment Request/Response bodies concatenate in order using the FIRST (0x01) and LAST (0x02) fragment flag bits.
|
||
|
||
### OXID resolution
|
||
|
||
`IObjectExporter::ResolveOxid` over port 135. Returns dual-string bindings; we filter for tower id `0x0007` (ncacn_ip_tcp) and parse `host[port]`.
|
||
|
||
Source: `src/MxNativeClient/ObjectExporterClient.cs:1–82`.
|
||
|
||
### OBJREF / IRemUnknown
|
||
|
||
OBJREF is a 68-byte STDOBJREF header + dual-string array. Signature `MEOW` = `0x574F454D`.
|
||
|
||
`IRemUnknown::RemQueryInterface` (opnum 3) yields a new IPID for a different IID on the same OXID. Used to obtain `INmxService2` from the activated `IUnknown`.
|
||
|
||
Source: `src/MxNativeClient/ComObjRef.cs:1–145`, `RemUnknownMessages.cs:1–79`.
|
||
|
||
### Public surface (sketch)
|
||
|
||
```rust
|
||
pub struct DceRpcClient { /* tokio::net::TcpStream + auth + frag state */ }
|
||
|
||
impl DceRpcClient {
|
||
pub async fn connect(addr: SocketAddr) -> Result<Self, RpcError>;
|
||
pub async fn bind(&mut self, iid: Uuid, ntlm: NtlmContext) -> Result<(), RpcError>;
|
||
pub async fn alter_context(&mut self, iid: Uuid) -> Result<(), RpcError>;
|
||
pub async fn call(&mut self, opnum: u16, stub: &[u8]) -> Result<Bytes, RpcError>;
|
||
}
|
||
```
|
||
|
||
`tokio::net::TcpStream` underneath. The PDU codec is a pure `tokio_util::codec::Decoder` so the same logic could in principle drive a different runtime if the I/O is provided.
|
||
|
||
## `mxaccess-callback`
|
||
|
||
Server-side. Listens on an ephemeral TCP port; serves Bind / Request PDUs for two interfaces:
|
||
|
||
- `IRemUnknown` (RemQueryInterface, RemAddRef, RemRelease)
|
||
- `INmxSvcCallback` (`DataReceived` opnum 3, `StatusReceived` opnum 4) — names match the .NET reference exactly: `src/MxNativeClient/NmxSvcCallbackMessages.cs:11–12` (`DataReceivedOpnum`/`StatusReceivedOpnum`) and `src/MxNativeClient/NmxProcedureMetadata.cs:89–101` (`NdrProcedureDescriptor` `DataReceived`/`StatusReceived`). The doc previously used a `Raw` suffix that does not appear in the source.
|
||
|
||
Source: `src/MxNativeClient/ManagedCallbackExporter.cs:1–335`.
|
||
|
||
```rust
|
||
pub struct CallbackExporter { /* TcpListener + dispatcher + frame channel */ }
|
||
|
||
impl CallbackExporter {
|
||
pub async fn bind(addr: SocketAddr) -> Result<Self, CallbackError>;
|
||
pub fn obj_ref(&self) -> ObjRef;
|
||
pub fn frames(&self) -> impl Stream<Item = CallbackFrame>;
|
||
}
|
||
|
||
pub enum CallbackFrame {
|
||
Data(Bytes), // INmxSvcCallback::DataReceived payload (opnum 3)
|
||
Status(Bytes), // INmxSvcCallback::StatusReceived payload (opnum 4)
|
||
}
|
||
```
|
||
|
||
Frames are not decoded here — they're forwarded to `mxaccess-codec::NmxSubscriptionMessage::parse` upstream. This keeps the callback exporter a transport, not a parser.
|
||
|
||
## `mxaccess-nmx`
|
||
|
||
`INmxService2` client. Sits on top of `mxaccess-rpc` and `mxaccess-callback`.
|
||
|
||
```rust
|
||
pub struct NmxClient { /* DceRpcClient + CallbackExporter handle + state */ }
|
||
|
||
impl NmxClient {
|
||
pub async fn connect(host: &str, ids: EngineIds) -> Result<Self, NmxError>;
|
||
pub async fn register_engine_2(
|
||
&mut self, engine_id: i32, name: &str, version: i32, callback: ObjRef,
|
||
) -> Result<(), NmxError>;
|
||
pub async fn unregister_engine(&mut self, engine_id: i32) -> Result<(), NmxError>;
|
||
pub async fn get_partner_version(&mut self, ids: EngineIds) -> Result<i32, NmxError>;
|
||
pub async fn transfer_data(&mut self, ids: EngineIds, body: &[u8]) -> Result<(), NmxError>;
|
||
pub async fn add_subscriber_engine(&mut self, ...) -> Result<(), NmxError>;
|
||
pub async fn remove_subscriber_engine(&mut self, ...) -> Result<(), NmxError>;
|
||
pub async fn set_heartbeat_send_interval(
|
||
&mut self, ticks_per_beat: i32, max_missed_ticks: i32,
|
||
) -> Result<(), NmxError>;
|
||
}
|
||
```
|
||
|
||
`transfer_data` accepts a pre-encoded body from `mxaccess-codec`. It does not decode; it forwards.
|
||
|
||
Includes the SQL tag resolver (`mxaccess-galaxy`):
|
||
|
||
```rust
|
||
pub struct GalaxyResolver { /* tiberius client */ }
|
||
|
||
impl GalaxyResolver {
|
||
pub async fn connect(connection_string: &str) -> Result<Self, GalaxyError>;
|
||
pub async fn resolve(&mut self, full_reference: &str) -> Result<GalaxyTagMetadata, GalaxyError>;
|
||
pub async fn resolve_user(&mut self, guid: Uuid) -> Result<GalaxyUser, GalaxyError>;
|
||
}
|
||
|
||
pub struct GalaxyTagMetadata {
|
||
pub object_tag_name: String,
|
||
pub attribute_name: String,
|
||
pub platform_id: u16,
|
||
pub engine_id: u16,
|
||
pub object_id: u16,
|
||
pub mx_data_type: MxDataType,
|
||
pub security_classification: SecurityClassification,
|
||
pub is_array: bool,
|
||
}
|
||
```
|
||
|
||
The resolver does not compute the CRC — consumers do that via `MxReferenceHandle::compute_name_signature` so the codec stays self-contained and the resolver stays a thin SQL layer.
|
||
|
||
## `mxaccess-asb`
|
||
|
||
`IASBIDataV2` client. SOAP over `net.tcp` framing. Independent of `mxaccess-rpc` and `mxaccess-nmx`; parallel data plane.
|
||
|
||
The wire is:
|
||
- Net.Tcp framing (binary length-prefixed, see [MS-NMF]).
|
||
- WCF binary message encoding ([MC-NBFX] tokenised XML + [MC-NBFS] static dictionary), **not** SOAP/XML on the wire — the .NET reference uses `new NetTcpBinding(SecurityMode.None)` with no encoder override (`src/MxAsbClient/MxAsbDataClient.cs:660-685`), which selects `BinaryMessageEncodingBindingElement` by default.
|
||
- Custom binary inside `<ASBIData>` base64 elements (Variant, AsbStatus, MonitoredItem...) — the inner application payload, distinct from the message-envelope encoding.
|
||
- Application-level auth: DH key exchange + per-message HMAC + AES-128.
|
||
|
||
Implementation: hand-rolled [MS-NMF] framing + [MC-NBFX]/[MC-NBFS] binary-XML codec in `mxaccess-asb-nettcp` (workspace-internal, published alongside the rest of the workspace), public surface in `mxaccess-asb`. Cross-platform reach is theoretically possible (no DCOM) but blocked by DPAPI for the shared secret on Windows AVEVA installs.
|
||
|
||
```rust
|
||
pub struct AsbClient { /* TcpStream + SOAP codec + auth + subscription state */ }
|
||
|
||
impl AsbClient {
|
||
pub async fn connect(endpoint: Url, options: AsbConnectionOptions) -> Result<Self, AsbError>;
|
||
pub async fn register(&mut self, item: &ItemIdentity) -> Result<RegisterResponse, AsbError>;
|
||
pub async fn read(&mut self, item: &ItemIdentity) -> Result<AsbValue, AsbError>;
|
||
pub async fn write(
|
||
&mut self, item: &ItemIdentity, value: AsbValue, opts: WriteOptions,
|
||
) -> Result<WriteHandle, AsbError>;
|
||
pub async fn create_subscription(
|
||
&mut self, opts: SubscriptionOptions,
|
||
) -> Result<SubscriptionId, AsbError>;
|
||
pub async fn add_monitored_items(
|
||
&mut self, sid: SubscriptionId, items: &[MonitoredItem],
|
||
) -> Result<(), AsbError>;
|
||
pub async fn publish(&mut self, sid: SubscriptionId) -> Result<Vec<PublishedValue>, AsbError>;
|
||
pub async fn disconnect(&mut self) -> Result<(), AsbError>;
|
||
}
|
||
```
|
||
|
||
`AsbClient` is async-native (unlike the .NET reference, which is sync) because Tokio + non-blocking sockets is the natural fit for a long-poll subscription API.
|
||
|
||
## What the raw layer does **not** do
|
||
|
||
- It does not own a Tokio `Runtime`. It uses one when handed sockets but does not start one.
|
||
- It does not surface `Stream<Item = DataChange>`. That is an async-layer concern. The raw callback exporter exposes `mpsc::Receiver<CallbackFrame>` of undecoded bytes; the async layer parses them and demultiplexes by correlation.
|
||
- It does not transform `MxStatus` into typed Rust errors. Status decoding is verbatim; the async layer maps to `Error` types (see `50-error-model.md`).
|
||
- It does not reconnect or retry. Recovery is an async-layer policy.
|
||
- It does not expose any sync wrappers. The raw types use `async fn` because every interesting operation is I/O-bound.
|