Compare commits
36 Commits
a5d31cc2e1
...
9063f10b1b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9063f10b1b | |||
| eb6c689f09 | |||
| 703c540bdc | |||
| cf97eab396 | |||
| 104efc4e9b | |||
| ce27b63010 | |||
| 42ac10a88f | |||
| fd38189f43 | |||
| f14580e0db | |||
| dbb580b2c8 | |||
| d1e887b91b | |||
| 4c4177050c | |||
| c2222b16b0 | |||
| 2867310817 | |||
| d4ee5f3a18 | |||
| 3b09297b27 | |||
| 4ebfd8e3a3 | |||
| e3baeb8803 | |||
| 9876b4ebb4 | |||
| 0441a2e693 | |||
| b543eb1f84 | |||
| c6570dcd06 | |||
| 14bb5297a8 | |||
| 8a0f92b6bc | |||
| 1b1ee1e0b7 | |||
| 321b7963a4 | |||
| 9b8133f725 | |||
| 1e59249662 | |||
| c4bf0a0a04 | |||
| a2b8989cbf | |||
| 25dbd8d3bd | |||
| 5f985588f7 | |||
| 43c10a15ca | |||
| 9dfd1937c2 | |||
| 7611d9e215 | |||
| ed17c07c10 |
+135
-1
@@ -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
|
||||
|
||||
Generated
+218
-2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶ms.prime_decimal)?;
|
||||
let generator = parse_decimal(¶ms.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", ¶ms, [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", ¶ms, [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", ¶ms, [1u8; 16]).unwrap();
|
||||
let mut bob = AsbAuthenticator::new("solution", ¶ms, [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", ¶ms, [1u8; 16]).unwrap();
|
||||
let bob = AsbAuthenticator::new("solution", ¶ms, [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", ¶ms, [1u8; 16]).unwrap();
|
||||
let bob = AsbAuthenticator::new("s", ¶ms, [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", ¶ms, [1u8; 16]).unwrap();
|
||||
let bob = AsbAuthenticator::new("s", ¶ms, [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", ¶ms, [1u8; 16]).unwrap();
|
||||
alice2.accept_connect_response(bob.local_public_key(), Some("PT5M"));
|
||||
assert!(!alice2.use_apollo_signing());
|
||||
|
||||
let mut alice3 = AsbAuthenticator::new("s", ¶ms, [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
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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.
|
||||
+21
@@ -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
|
||||
@@ -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
@@ -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
@@ -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("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'&' => out.push_str("&"),
|
||||
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 `<` and `&`. 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 < b & c > 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).
|
||||
+13
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 -------------------------------------------------
|
||||
|
||||
|
||||
@@ -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")?,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
/// `<a:To>` 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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user