6 Commits

Author SHA1 Message Date
Joseph Doherty 1f07da2e12 tools: upgrade Get-InfisicalSecret to stream separation, drop banner regex
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled
Earlier fix (commit 047125b) filtered the infisical CLI's
"A new release of infisical is available" upgrade banner from
captured output via regex matching. That worked but coupled the
filter to specific banner-pattern strings — a future banner shape
("Update available" / "New version detected" / a localized
message) would slip through and break NTLM Type1 auth again.

The principled fix is to stop capturing stderr at all.
PowerShell's call operator (`&`) keeps stdout and stderr on
separate streams unless explicitly merged; the previous code's
`2>&1` was the actual mistake. Without it, the banner stays in
the error stream (visible on the console for diagnostics) and
the captured `$value` contains only the script's stdout — which
for `Get-Secret.ps1` is just the secret value from `infisical
secrets get --plain`.

Verified: live re-run of F54 (lmx_write_complete_live) passes
post-change with `MX_TEST_DOMAIN='DESKTOP-6JL3KKO'` clean and
the banner visibly logged to console (stderr) above each [SET]
line. No regex coupling to a specific banner-pattern remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:30:52 -04:00
Joseph Doherty 047125bc11 M6 live verification: re-run all 5 steps + filter infisical banner
Three doc fixes pinned by re-running today's full live-test sweep:

1. Bump status header from 2026-05-06 to "re-run 2026-05-07" with a
   note that all 5 steps still pass against the live AVEVA install.
   The first run of step 1 + step 5 today failed with
   `Error::Status { detail: 5 }` (DCE/RPC fault 0x00000005) traced
   to MX_TEST_DOMAIN being polluted with the infisical CLI's
   "A new release of infisical is available" upgrade banner. The
   banner was being concatenated onto the domain string by
   Setup-LiveProbeEnv.ps1's `2>&1` capture, causing NTLM Type1 to
   send a malformed domain field that NmxSvc rejected.

2. Fix tools/Setup-LiveProbeEnv.ps1 — Get-InfisicalSecret now splits
   captured output on newlines, filters lines matching the
   "^A new release of infisical is available" / "^Please upgrade"
   banner patterns, and returns the last non-empty line (the actual
   secret value from `infisical secrets get --plain`). Robust to
   future banner messages of similar shape.

3. Fix two drifted line citations in docs/M6-live-verification.md:
   `recover_connection_core (session.rs:1428-...)` is now at line
   1374 after F56/F45/F47 edits — strip the line number, keep the
   function name (`Session::recover_connection_core`). Same for
   `Session::unsubscribe (session.rs:2261)`.

4. Add "Workspace gate (no live infra needed)" subsection to the
   "Reproducing locally" recipe so a fresh contributor sees the
   full V1 verification recipe (live + workspace gate) in one place.

All 5 live tests pass post-fix:
  - F36 buffered subscribe (drained 1 raw NMX message; no scan
    activity on TestChangingInt today, matches 5/6 baseline)
  - F45 buffered recovery replay (2 pre + 2 post DataUpdate frames)
  - F47 buffered unsubscribe skip (returned Ok)
  - F40 metrics smoke (4 expected metric names present)
  - F54 OnWriteComplete (status detail 9 = WRITE_COMPLETE_OK)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:17:46 -04:00
Joseph Doherty f14580e0db [M5] mxaccess-asb: F28 canonical-XML signing wired + registry-driven DH params
Adds `xml_canonical` module that emits XmlSerializer-compatible canonical
XML for the five primary `ConnectedRequest` shapes (AuthenticateMe,
Disconnect, KeepAlive, RegisterItemsRequest, UnregisterItemsRequest).
Six fixture-comparison tests verify byte-exact match against captured
.NET output, including the empty-MAC-IV variant that the live signing
flow uses (`authenticate-me-empty-mac-iv.xml`, 896 bytes; new
`emit_data_ns_byte_array` helper picks self-closing form for empty
byte[]).

Plumbing: `AsbAuthenticator::peek_next_message_number` exposes the
pre-allocated message number; `AsbClient::send_signed_envelope[_one_way]`
gain an `xml_for_signing: Option<&[u8]>` parameter. `connect`,
`disconnect`, `keep_alive`, `register_items`, `unregister_items` now
build a pre-signing `ConnectionValidator` (empty MAC + IV) + emit the
canonical XML + pass the bytes through to HMAC. Other ops (Read, Write,
Subscription) keep the legacy NBFX-bytes path until F28 expands to
cover their request shapes.

Live-bring-up wiring:
- `tools/Get-AsbPassphrase.ps1` now exports `MX_ASB_DH_PRIME`,
  `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM` (always — even when
  empty, so the example can distinguish "no env var" from "registry
  says empty"), and `MX_ASB_DH_KEY_SIZE`.
- `examples/asb-subscribe.rs` honours those env vars to override
  `CryptoParameters::defaults()`. Each AVEVA install picks its own DH
  group at provisioning time (768-bit prime is typical, vs the .NET
  reference's 1024-bit fallback that we previously hardcoded). Empty
  hashAlgorithm in the registry maps to `HashAlgorithm::Unrecognised`,
  matching `AsbSystemAuthenticator.CreateHmac:84-93` semantics where
  empty + forceHmac=true → HMAC-SHA1.
- `MxAsbClient.Probe --dump-signed-xml` flag (added in earlier commit)
  now traces the live HMAC inputs (`asb.sign.xml-utf8-len`,
  `asb.sign.xml-b64`, `asb.sign.hmac-b64`, etc.) so the Rust port can
  diff its canonical XML against .NET's byte-for-byte for any live
  scenario (env-driven via `Action<string>? sharedTrace`).

Wire-format alignment for `XmlSerializer` parity:
- `ItemIdentity::default()` and `absolute_by_name` now use
  `Some(String::new())` for null-able strings (matches .NET's
  `CreateAbsoluteItem` setting `ContextName = string.Empty` not null).
- `read_unicode_string` returns `Some(String::new())` for length-0
  rather than `None` — mirrors .NET's `AsbBinary.ReadUnicodeString:
  return string.Empty for byteLength == 0`. Wire format genuinely
  cannot distinguish null from empty (both encode as 4 bytes of zero);
  callers that need to preserve the distinction MUST track it in their
  domain types before encoding.

Live status (post-fix): Connect handshake completes end-to-end. The
canonical XML our emitter produces matches .NET's structure byte-for-
byte (verified by fixture comparison). DH prime/generator/hash now
match the live registry values. Despite all this, AuthenticateMe
still produces a generic dispatcher fault on the server — there's at
least one more subtle wire-byte or crypto mismatch that needs
isolation. F28 stays open with that note.

Workspace: 709 unit tests pass (was 702 + 7 new xml_canonical tests).
Clippy: clean (`-D warnings`).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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