fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/ .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
MxAsbClient, probes, tests, harnesses. Executable spec.
- design/ Architectural plan for the Rust port (M0–M6), error
model, protocol invariants, risks (R1–R16), adversarial
review log (review.md).
- rust/ Rust workspace. M0 skeleton + M1 codec parity.
mxaccess-codec: 215 unit tests + 2 cross-implementation
parity tests (byte-identical against .NET reference).
Other crates are M0 stubs awaiting M2+.
- captures/ Frida + netsh + pcap evidence per CLAUDE.md
("captures are evidence, not throwaway logs").
- analysis/ Decompiled C# (frida/proxy/decompiled-*),
Ghidra exports for native DLLs (`exports/` only —
working state at `projects/` and AVEVA's input
binaries at `input/` are gitignored).
- docs/ Reverse-engineering reference docs.
- tools/ Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/ Rust CI: fmt + build + test + clippy on Windows.
- LICENSE MIT (Joseph Doherty, 2026).
Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly
Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
821 lines
30 KiB
Rust
821 lines
30 KiB
Rust
//! `NmxSecuredWrite2Message` — secured timestamped-write (`0x38`) message
|
|
//! body codec.
|
|
//!
|
|
//! Direct port of `src/MxNativeCodec/NmxSecuredWrite2Message.cs`.
|
|
//!
|
|
//! ## Naming and the single-token form
|
|
//!
|
|
//! The .NET method name is `WriteSecured2`. It always carries **two** user
|
|
//! identifiers (`currentUserToken` and `verifierUserToken`) plus a timestamp
|
|
//! and a client name — there is no separate single-token form on the LMX
|
|
//! wire. Single-user secured writes pass `currentUserToken == verifierUserToken`
|
|
//! (per `wwtools/mxaccesscli/docs/api-notes.md:60-72` verification noted in
|
|
//! `design/40-protocol-invariants.md` after the MAJOR-pass audit).
|
|
//!
|
|
//! The R6 entry in `design/70-risks-and-open-questions.md:174` confirms this:
|
|
//! the production LMX surface accepts `WriteSecured` with two user ids
|
|
//! unconditionally, and the captured `0x38` shape always has both token slots.
|
|
//!
|
|
//! ## Body layout
|
|
//!
|
|
//! The secured body inherits the `Write2` (timestamped) prefix shape, then
|
|
//! appends authentication / verification fields **before** the trailing
|
|
//! `(-1 i16) + clientToken(u32) + writeIndex(i32)` slot
|
|
//! (`NmxSecuredWrite2Message.cs:40-69`).
|
|
//!
|
|
//! ```text
|
|
//! offset size field source
|
|
//! 0..N N = Write2 prefix timestamped Write2 body up to but
|
|
//! NOT including the 4-byte clientToken
|
|
//! and 4-byte writeIndex .cs:41-54
|
|
//! N 16 currentUserToken .cs:56
|
|
//! N+16 4 clientNameLen i32 LE .cs:58
|
|
//! N+20 clientNameLen clientNameBytes (UTF-16LE + NUL) .cs:60
|
|
//! N+20+L 16 verifierUserToken .cs:62
|
|
//! N+36+L 2 -1 i16 LE .cs:64
|
|
//! N+38+L 4 clientToken u32 LE .cs:66
|
|
//! N+42+L 4 writeIndex i32 LE .cs:68
|
|
//! ```
|
|
//!
|
|
//! `prefixLength = timestampedPrefix.Length - sizeof(uint) - sizeof(int)`
|
|
//! (`.cs:51`) — i.e. the timestamped body **minus** its trailing 8 bytes
|
|
//! (clientToken + writeIndex). The opcode byte is overwritten from `0x37`
|
|
//! to `0x38` after the timestamped encoder runs (`.cs:48`).
|
|
//!
|
|
//! ## Observed authenticated-user token
|
|
//!
|
|
//! The .NET reference exposes a sample observed token at `.cs:12-18`. It is
|
|
//! mirrored here as [`OBSERVED_AUTHENTICATED_USER_TOKEN`] for tests and
|
|
//! probes that want to replay the captured `captures/036-frida-secured*`
|
|
//! body (cited in `design/40-protocol-invariants.md:164`).
|
|
|
|
// Direct byte indexing — see reference_handle.rs for rationale.
|
|
#![allow(clippy::indexing_slicing)]
|
|
|
|
use crate::MxReferenceHandle;
|
|
use crate::error::CodecError;
|
|
use crate::write_message::{self, WriteValue};
|
|
|
|
/// Secured-write opcode (`NmxSecuredWrite2Message.cs:8`).
|
|
pub const COMMAND: u8 = 0x38;
|
|
|
|
/// Wire-format version (`NmxSecuredWrite2Message.cs:9`). The .NET `Encode`
|
|
/// path defers to `NmxWriteMessage.EncodeTimestamped` which writes
|
|
/// `version = 1` (`NmxWriteMessage.cs:10`); the constant is preserved here
|
|
/// for parity with the .NET surface.
|
|
pub const VERSION: u16 = 1;
|
|
|
|
/// Authenticator token length in bytes (`NmxSecuredWrite2Message.cs:10`).
|
|
pub const AUTHENTICATOR_TOKEN_LENGTH: usize = 16;
|
|
|
|
/// Sample observed authenticated-user token from the live AVEVA stack
|
|
/// (`NmxSecuredWrite2Message.cs:12-18`, captured in
|
|
/// `captures/036-frida-secured*`).
|
|
pub const OBSERVED_AUTHENTICATED_USER_TOKEN: [u8; AUTHENTICATOR_TOKEN_LENGTH] = [
|
|
0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89, 0x0f,
|
|
];
|
|
|
|
/// Resolve the observed token form for a given user id. Mirrors
|
|
/// `ResolveObservedUserToken` (`NmxSecuredWrite2Message.cs:94-99`): user id
|
|
/// `0` returns 16 zero bytes; any other id returns the observed authenticated
|
|
/// token. This helper is for tests / probes; production callers should pass
|
|
/// real tokens.
|
|
pub fn resolve_observed_user_token(user_id: i32) -> [u8; AUTHENTICATOR_TOKEN_LENGTH] {
|
|
if user_id == 0 {
|
|
[0u8; AUTHENTICATOR_TOKEN_LENGTH]
|
|
} else {
|
|
OBSERVED_AUTHENTICATED_USER_TOKEN
|
|
}
|
|
}
|
|
|
|
/// Encode a `WriteSecured2` body (`0x38`).
|
|
///
|
|
/// Mirrors `NmxSecuredWrite2Message.Encode` (`NmxSecuredWrite2Message.cs:20-70`).
|
|
/// Internally builds a timestamped Write2 body via
|
|
/// [`crate::write_message::encode_timestamped`], strips its trailing 8 bytes
|
|
/// (clientToken + writeIndex), overwrites the leading opcode byte, and
|
|
/// appends the secured suffix.
|
|
///
|
|
/// `current_user_token == verifier_user_token` is the single-user secured
|
|
/// write path and is allowed unconditionally — see module doc.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// - Returns a [`CodecError::Decode`] if the underlying timestamped Write
|
|
/// encode fails (e.g. array element count exceeds `u16::MAX`).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn encode(
|
|
handle: &MxReferenceHandle,
|
|
value: &WriteValue,
|
|
current_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
|
|
verifier_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
|
|
client_name: &str,
|
|
timestamp_filetime: i64,
|
|
write_index: i32,
|
|
client_token: u32,
|
|
) -> Result<Vec<u8>, CodecError> {
|
|
// 1. Build the timestamped Write2 body. The .NET reference passes
|
|
// `clientToken: 0` (`NmxSecuredWrite2Message.cs:47`) — those 8 trailing
|
|
// bytes are about to be stripped, so the value is irrelevant.
|
|
let timestamped =
|
|
write_message::encode_timestamped(handle, value, timestamp_filetime, write_index, 0)?;
|
|
|
|
// 2. Strip the trailing clientToken (4) + writeIndex (4) from the
|
|
// timestamped body — `prefixLength = ts.Length - 4 - 4`
|
|
// (`NmxSecuredWrite2Message.cs:51`).
|
|
let prefix_length = timestamped.len() - 4 - 4;
|
|
|
|
// 3. UTF-16LE + NUL terminator for the client name
|
|
// (`NmxSecuredWrite2Message.cs:50`,
|
|
// `Encoding.Unicode.GetBytes(clientName + '\0')`).
|
|
let client_name_bytes = encode_utf16_with_nul(client_name);
|
|
let client_name_len = client_name_bytes.len();
|
|
|
|
// 4. Allocate body of the exact final size
|
|
// (`NmxSecuredWrite2Message.cs:52`).
|
|
let body_len = prefix_length
|
|
+ AUTHENTICATOR_TOKEN_LENGTH
|
|
+ 4
|
|
+ client_name_len
|
|
+ AUTHENTICATOR_TOKEN_LENGTH
|
|
+ 2
|
|
+ 4
|
|
+ 4;
|
|
let mut body = vec![0u8; body_len];
|
|
|
|
// 5. Copy stripped timestamped prefix and overwrite opcode
|
|
// (`NmxSecuredWrite2Message.cs:48, 54`).
|
|
body[..prefix_length].copy_from_slice(×tamped[..prefix_length]);
|
|
body[0] = COMMAND;
|
|
|
|
// 6. Append secured suffix in declared order
|
|
// (`NmxSecuredWrite2Message.cs:55-69`).
|
|
let mut offset = prefix_length;
|
|
body[offset..offset + AUTHENTICATOR_TOKEN_LENGTH].copy_from_slice(¤t_user_token);
|
|
offset += AUTHENTICATOR_TOKEN_LENGTH;
|
|
write_i32_le(&mut body, offset, client_name_len as i32);
|
|
offset += 4;
|
|
body[offset..offset + client_name_len].copy_from_slice(&client_name_bytes);
|
|
offset += client_name_len;
|
|
body[offset..offset + AUTHENTICATOR_TOKEN_LENGTH].copy_from_slice(&verifier_user_token);
|
|
offset += AUTHENTICATOR_TOKEN_LENGTH;
|
|
write_i16_le(&mut body, offset, -1);
|
|
offset += 2;
|
|
write_u32_le(&mut body, offset, client_token);
|
|
offset += 4;
|
|
write_i32_le(&mut body, offset, write_index);
|
|
|
|
Ok(body)
|
|
}
|
|
|
|
/// Decoded secured-write body.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct DecodedSecuredWrite {
|
|
/// Inner timestamped Write2 result (handle projection, value, write
|
|
/// index, client token, timestamp). Note the `client_token` and
|
|
/// `write_index` of the inner result come from the **secured** suffix —
|
|
/// the original timestamped body was encoded with `client_token = 0`
|
|
/// before the trailing 8 bytes were stripped (.cs:47, .cs:51), so the
|
|
/// decoder reconstructs a synthetic timestamped body with the secured
|
|
/// suffix's clientToken+writeIndex re-attached for round-trip parity.
|
|
pub inner: write_message::DecodedWrite,
|
|
pub current_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
|
|
pub verifier_user_token: [u8; AUTHENTICATOR_TOKEN_LENGTH],
|
|
pub client_name: String,
|
|
}
|
|
|
|
/// Decode a `WriteSecured2` body produced by [`encode`].
|
|
///
|
|
/// The .NET reference is encode-only (`NmxSecuredWrite2Message.cs:6-105`); the
|
|
/// Rust port adds a decoder for round-trip tests, mirroring the encoder
|
|
/// layout exactly.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// - [`CodecError::ShortRead`] if `body` is too small to carry the secured
|
|
/// suffix.
|
|
/// - [`CodecError::UnexpectedOpcode`] if `body[0] != 0x38`.
|
|
/// - [`CodecError::Decode`] for malformed lengths or invalid client-name UTF-16.
|
|
/// - Any error returned by [`crate::write_message::decode`] for the inner
|
|
/// reconstructed timestamped body.
|
|
pub fn decode(body: &[u8]) -> Result<DecodedSecuredWrite, CodecError> {
|
|
if body.is_empty() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: 1,
|
|
actual: 0,
|
|
});
|
|
}
|
|
if body[0] != COMMAND {
|
|
return Err(CodecError::UnexpectedOpcode(body[0]));
|
|
}
|
|
|
|
// Trailing slot: 16 (verifier) + 2 (-1 i16) + 4 (clientToken) + 4 (writeIndex)
|
|
// = 26 bytes after the client-name region.
|
|
// The minimum body shape is: prefix(>=18) + currentToken(16) + nameLen(4)
|
|
// + nameBytes(>=2 NUL) + verifierToken(16) + 10-byte tail.
|
|
if body.len() < 1 + AUTHENTICATOR_TOKEN_LENGTH + 4 + 2 + AUTHENTICATOR_TOKEN_LENGTH + 10 {
|
|
return Err(CodecError::ShortRead {
|
|
expected: 1 + AUTHENTICATOR_TOKEN_LENGTH + 4 + 2 + AUTHENTICATOR_TOKEN_LENGTH + 10,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
|
|
// Strategy: walk back from the end. The last 26 bytes are the secured
|
|
// suffix; before that lies a UTF-16LE client_name of length declared in
|
|
// the i32 LE that precedes it; before THAT lies the 16-byte
|
|
// currentUserToken; before THAT lies the timestamped Write2 prefix
|
|
// (without its trailing 8 bytes). We don't know the prefix length up
|
|
// front, but we can locate the secured suffix by scanning for the
|
|
// currentUserToken offset using the client-name length field.
|
|
//
|
|
// Concretely: the last 26 bytes are
|
|
// verifier(16) + -1 i16 + clientToken(4) + writeIndex(4) = 26
|
|
// Before them is the client_name of length L (variable). Before THAT
|
|
// is the i32 LE clientNameLen (4). Before THAT is the currentUserToken (16).
|
|
//
|
|
// We need to find the offset where currentUserToken starts. We do that
|
|
// by reading clientNameLen from a position relative to the end:
|
|
// offset_of_clientNameLen = body.len() - 26 - L - 4
|
|
// offset_of_clientNameLen + 4 + L + 16 + 2 + 4 + 4 = body.len()
|
|
//
|
|
// Equivalently: the trailing region after the timestamped prefix is
|
|
// 16 + 4 + L + 16 + 2 + 4 + 4 = 46 + L bytes.
|
|
//
|
|
// So prefix_length = body.len() - (46 + L).
|
|
//
|
|
// We don't know L without locating clientNameLen first. The .NET
|
|
// reference does not record where the prefix ends — it derives it on
|
|
// encode. On decode, we need to use the inner write_message::decode to
|
|
// figure out the timestamped body's natural length, then derive L.
|
|
//
|
|
// Approach: reconstruct a synthetic timestamped body by appending an
|
|
// 8-byte clientToken+writeIndex tail with zeros to a candidate prefix,
|
|
// decode it, and use the resulting body length to find the boundary.
|
|
//
|
|
// Simpler: walk forward through the timestamped wire shape using the
|
|
// crate's own write_message::decode after we know the prefix bytes.
|
|
// But we don't know the prefix length yet.
|
|
//
|
|
// The cleanest approach: read the trailing structure. We know the body
|
|
// tail layout. Count bytes from the end:
|
|
// [body.len() - 4 .. body.len()] writeIndex i32
|
|
// [body.len() - 8 .. body.len() - 4] clientToken u32
|
|
// [body.len() - 10 .. body.len() - 8] -1 i16
|
|
// [body.len() - 26 .. body.len() - 10] verifierUserToken (16)
|
|
// [body.len() - 26 - L .. body.len() - 26] clientNameBytes (L)
|
|
// [body.len() - 30 - L .. body.len() - 26 - L] clientNameLen i32 (4)
|
|
// [body.len() - 46 - L .. body.len() - 30 - L] currentUserToken (16)
|
|
// We need L. The clientNameLen i32 lives at offset (body.len() - 30 - L).
|
|
// We can solve by scanning candidate L values OR by realising that the
|
|
// timestamped prefix has a deterministic length given the value kind.
|
|
//
|
|
// We use the deterministic-length approach: rebuild a candidate
|
|
// timestamped prefix of length `prefix_length`, then decode by parsing
|
|
// the wire-kind-driven shape. Since the inner write_message::decode
|
|
// already implements this, and since the prefix shape is fully
|
|
// determined by body[1..3] (version) and body[17] (wire_kind), we can
|
|
// compute the timestamped body length without seeing the secured suffix.
|
|
|
|
// body[0..18] is the common prefix: cmd + version + 14 handle bytes + wire_kind.
|
|
let wire_kind = body[17];
|
|
|
|
// For each wire kind, the timestamped body length is fixed (scalar) or
|
|
// determined by an inner length prefix (variable / array). The
|
|
// timestamped body length = prefix_length + 8 (the 8 stripped trailing
|
|
// bytes). prefix_length = body.len() - 46 - L. We don't know L.
|
|
//
|
|
// But we DO know the timestamped body length from wire_kind directly:
|
|
let ts_body_len = compute_timestamped_body_len(body, wire_kind)?;
|
|
let prefix_length = ts_body_len - 8;
|
|
|
|
// Now layout is known.
|
|
let suffix_offset = prefix_length;
|
|
if suffix_offset + AUTHENTICATOR_TOKEN_LENGTH + 4 > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: suffix_offset + AUTHENTICATOR_TOKEN_LENGTH + 4,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
|
|
let mut current_user_token = [0u8; AUTHENTICATOR_TOKEN_LENGTH];
|
|
current_user_token
|
|
.copy_from_slice(&body[suffix_offset..suffix_offset + AUTHENTICATOR_TOKEN_LENGTH]);
|
|
|
|
let name_len_offset = suffix_offset + AUTHENTICATOR_TOKEN_LENGTH;
|
|
let client_name_len = read_i32_le(body, name_len_offset);
|
|
if client_name_len < 0 {
|
|
return Err(CodecError::Decode {
|
|
offset: name_len_offset,
|
|
reason: "secured-write: negative clientNameLen",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
let client_name_len = client_name_len as usize;
|
|
|
|
let name_offset = name_len_offset + 4;
|
|
if name_offset + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 10 > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: name_offset + client_name_len + AUTHENTICATOR_TOKEN_LENGTH + 10,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
|
|
let client_name_bytes = &body[name_offset..name_offset + client_name_len];
|
|
let client_name = decode_utf16_with_nul(client_name_bytes, name_offset, body.len())?;
|
|
|
|
let verifier_offset = name_offset + client_name_len;
|
|
let mut verifier_user_token = [0u8; AUTHENTICATOR_TOKEN_LENGTH];
|
|
verifier_user_token
|
|
.copy_from_slice(&body[verifier_offset..verifier_offset + AUTHENTICATOR_TOKEN_LENGTH]);
|
|
|
|
let tail_offset = verifier_offset + AUTHENTICATOR_TOKEN_LENGTH;
|
|
let leading = read_i16_le(body, tail_offset);
|
|
if leading != -1 {
|
|
return Err(CodecError::Decode {
|
|
offset: tail_offset,
|
|
reason: "secured-write: trailing leading i16 is not -1",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
let secured_client_token = read_u32_le(body, tail_offset + 2);
|
|
let secured_write_index = read_i32_le(body, tail_offset + 6);
|
|
|
|
// Reconstruct the timestamped body so we can call write_message::decode.
|
|
// The .NET encoder calls EncodeTimestamped with clientToken=0 then
|
|
// strips, so the original prefix has 0 in the clientToken slot. We need
|
|
// to substitute the secured suffix's clientToken+writeIndex back so the
|
|
// inner DecodedWrite reflects what the caller passed to encode().
|
|
let mut ts_body = body[..prefix_length].to_vec();
|
|
ts_body.extend_from_slice(&secured_client_token.to_le_bytes());
|
|
ts_body.extend_from_slice(&secured_write_index.to_le_bytes());
|
|
// Restore the inner opcode (was overwritten to 0x38; restore to 0x37
|
|
// so write_message::decode accepts it).
|
|
ts_body[0] = write_message::COMMAND;
|
|
|
|
let inner = write_message::decode(&ts_body)?;
|
|
|
|
Ok(DecodedSecuredWrite {
|
|
inner,
|
|
current_user_token,
|
|
verifier_user_token,
|
|
client_name,
|
|
})
|
|
}
|
|
|
|
/// Compute the length of the timestamped Write2 body for a given wire kind,
|
|
/// reading any inner length fields from `body` (which carries a `0x38` body —
|
|
/// but the prefix bytes are identical to the timestamped `0x37` body).
|
|
fn compute_timestamped_body_len(body: &[u8], wire_kind: u8) -> Result<usize, CodecError> {
|
|
// Timestamped body shapes (mirroring write_message.rs):
|
|
// Boolean (timestamped): 17 + 1 + 1 + 14 + 4 = 37
|
|
// [actually: KIND_OFFSET(17) + 1 + 1-byte payload + 18-byte suffix
|
|
// = 37]. Per write_message.rs:357-364.
|
|
// Int32: 17 + 1 + 4 + 14 + 4 = 40
|
|
// Float32: 40
|
|
// Float64: 17 + 1 + 8 + 14 + 4 = 44
|
|
// Variable: 44 + utf16_len (read inner_len at offset 22)
|
|
// Array: 46 + payload_len (count u16 at 22, walk for variable arrays)
|
|
match wire_kind {
|
|
0x01 => Ok(37),
|
|
0x02 | 0x03 => Ok(40),
|
|
0x04 => Ok(44),
|
|
0x05 => {
|
|
// body[22..26] = inner_length (i32 LE) — UTF-16 byte length
|
|
// including 2-byte NUL terminator.
|
|
if body.len() < 26 {
|
|
return Err(CodecError::ShortRead {
|
|
expected: 26,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
let inner_len = read_i32_le(body, 22);
|
|
if inner_len < 0 {
|
|
return Err(CodecError::Decode {
|
|
offset: 22,
|
|
reason: "secured-write: negative variable inner_length",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
Ok(44 + inner_len as usize)
|
|
}
|
|
0x41..=0x44 => {
|
|
if body.len() < 28 {
|
|
return Err(CodecError::ShortRead {
|
|
expected: 28,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
let count = read_u16_le(body, 22) as usize;
|
|
// `wire_kind` is constrained to 0x41..=0x44 by the outer match
|
|
// arm; default to 2 for any out-of-table value (shouldn't occur).
|
|
let element_width = match wire_kind {
|
|
0x41 => 2,
|
|
0x42 | 0x43 => 4,
|
|
0x44 => 8,
|
|
_ => 2,
|
|
};
|
|
Ok(28 + count * element_width + 18)
|
|
}
|
|
0x45 => {
|
|
if body.len() < 28 {
|
|
return Err(CodecError::ShortRead {
|
|
expected: 28,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
let count = read_u16_le(body, 22) as usize;
|
|
let mut cursor = 28usize;
|
|
for _ in 0..count {
|
|
if cursor + 13 > body.len() {
|
|
return Err(CodecError::ShortRead {
|
|
expected: cursor + 13,
|
|
actual: body.len(),
|
|
});
|
|
}
|
|
let inner_len = read_i32_le(body, cursor + 9);
|
|
if inner_len < 0 {
|
|
return Err(CodecError::Decode {
|
|
offset: cursor + 9,
|
|
reason: "secured-write: negative variable-array inner_length",
|
|
buffer_len: body.len(),
|
|
});
|
|
}
|
|
cursor += 13 + inner_len as usize;
|
|
}
|
|
Ok(cursor + 18)
|
|
}
|
|
_ => Err(CodecError::Decode {
|
|
offset: 17,
|
|
reason: "secured-write: unknown wire kind",
|
|
buffer_len: body.len(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
// ---- UTF-16 helpers -------------------------------------------------------
|
|
|
|
/// UTF-16LE encoding with a trailing 2-byte NUL terminator.
|
|
/// Mirrors `Encoding.Unicode.GetBytes(clientName + '\0')`
|
|
/// (`NmxSecuredWrite2Message.cs:50`).
|
|
fn encode_utf16_with_nul(value: &str) -> Vec<u8> {
|
|
let utf16: Vec<u16> = value.encode_utf16().collect();
|
|
let mut bytes = Vec::with_capacity(utf16.len() * 2 + 2);
|
|
for unit in &utf16 {
|
|
bytes.extend_from_slice(&unit.to_le_bytes());
|
|
}
|
|
// Trailing NUL (the `+ '\0'` in the .NET source).
|
|
bytes.push(0x00);
|
|
bytes.push(0x00);
|
|
bytes
|
|
}
|
|
|
|
fn decode_utf16_with_nul(
|
|
raw: &[u8],
|
|
offset: usize,
|
|
buffer_len: usize,
|
|
) -> Result<String, CodecError> {
|
|
if raw.len() % 2 != 0 {
|
|
return Err(CodecError::Decode {
|
|
offset,
|
|
reason: "secured-write: client_name byte length is not even",
|
|
buffer_len,
|
|
});
|
|
}
|
|
let utf16: Vec<u16> = raw
|
|
.chunks_exact(2)
|
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
|
.collect();
|
|
// Strip the trailing NUL terminator (the .NET path always emits one).
|
|
let trimmed: &[u16] = if utf16.last() == Some(&0) {
|
|
&utf16[..utf16.len() - 1]
|
|
} else {
|
|
&utf16
|
|
};
|
|
String::from_utf16(trimmed).map_err(|_| CodecError::Decode {
|
|
offset,
|
|
reason: "secured-write: invalid UTF-16 in client_name",
|
|
buffer_len,
|
|
})
|
|
}
|
|
|
|
// ---- LE primitive helpers -------------------------------------------------
|
|
|
|
#[inline]
|
|
fn write_i16_le(bytes: &mut [u8], offset: usize, value: i16) {
|
|
bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
#[inline]
|
|
fn write_i32_le(bytes: &mut [u8], offset: usize, value: i32) {
|
|
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
#[inline]
|
|
fn write_u32_le(bytes: &mut [u8], offset: usize, value: u32) {
|
|
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
#[inline]
|
|
fn read_i16_le(bytes: &[u8], offset: usize) -> i16 {
|
|
i16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
|
}
|
|
|
|
#[inline]
|
|
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
|
|
i32::from_le_bytes([
|
|
bytes[offset],
|
|
bytes[offset + 1],
|
|
bytes[offset + 2],
|
|
bytes[offset + 3],
|
|
])
|
|
}
|
|
|
|
#[inline]
|
|
fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
|
|
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
|
|
}
|
|
|
|
#[inline]
|
|
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
|
|
u32::from_le_bytes([
|
|
bytes[offset],
|
|
bytes[offset + 1],
|
|
bytes[offset + 2],
|
|
bytes[offset + 3],
|
|
])
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Tests
|
|
// ===========================================================================
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_handle() -> MxReferenceHandle {
|
|
MxReferenceHandle::from_names(
|
|
1,
|
|
42,
|
|
17,
|
|
300,
|
|
"TestChildObject",
|
|
-1,
|
|
7,
|
|
0,
|
|
"TestInt",
|
|
false,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
const TOKEN_A: [u8; 16] = [
|
|
0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c, 0x89,
|
|
0x0f,
|
|
];
|
|
const TOKEN_B: [u8; 16] = [
|
|
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
|
0x00,
|
|
];
|
|
|
|
#[test]
|
|
fn opcode_and_constants_match_dotnet() {
|
|
// `NmxSecuredWrite2Message.cs:8-10`.
|
|
assert_eq!(COMMAND, 0x38);
|
|
assert_eq!(VERSION, 1);
|
|
assert_eq!(AUTHENTICATOR_TOKEN_LENGTH, 16);
|
|
}
|
|
|
|
#[test]
|
|
fn observed_authenticated_user_token_matches_dotnet() {
|
|
// `NmxSecuredWrite2Message.cs:12-18`.
|
|
assert_eq!(
|
|
OBSERVED_AUTHENTICATED_USER_TOKEN,
|
|
[
|
|
0x07, 0xb9, 0xa9, 0xf4, 0x72, 0x6e, 0xae, 0x48, 0x83, 0xb5, 0xbb, 0xde, 0x91, 0x8c,
|
|
0x89, 0x0f
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_observed_user_token_matches_dotnet() {
|
|
// `NmxSecuredWrite2Message.cs:94-99`.
|
|
assert_eq!(resolve_observed_user_token(0), [0u8; 16]);
|
|
assert_eq!(
|
|
resolve_observed_user_token(123),
|
|
OBSERVED_AUTHENTICATED_USER_TOKEN
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opcode_byte_is_overwritten_to_0x38() {
|
|
// `NmxSecuredWrite2Message.cs:48`.
|
|
let h = sample_handle();
|
|
let body = encode(
|
|
&h,
|
|
&WriteValue::Int32(123),
|
|
TOKEN_A,
|
|
TOKEN_B,
|
|
"client",
|
|
123_456_789_i64,
|
|
5,
|
|
0xCAFE_BABE,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(body[0], COMMAND);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_int32_two_distinct_user_tokens() {
|
|
let h = sample_handle();
|
|
let body = encode(
|
|
&h,
|
|
&WriteValue::Int32(0x1234_5678),
|
|
TOKEN_A,
|
|
TOKEN_B,
|
|
"TestClient",
|
|
0x0102_0304_0506_0708_i64,
|
|
42,
|
|
0xDEAD_BEEF,
|
|
)
|
|
.unwrap();
|
|
let decoded = decode(&body).unwrap();
|
|
assert_eq!(decoded.current_user_token, TOKEN_A);
|
|
assert_eq!(decoded.verifier_user_token, TOKEN_B);
|
|
assert_ne!(decoded.current_user_token, decoded.verifier_user_token);
|
|
assert_eq!(decoded.client_name, "TestClient");
|
|
assert_eq!(decoded.inner.value, WriteValue::Int32(0x1234_5678));
|
|
assert_eq!(decoded.inner.write_index, 42);
|
|
assert_eq!(decoded.inner.client_token, 0xDEAD_BEEF);
|
|
assert_eq!(
|
|
decoded.inner.timestamp_filetime,
|
|
Some(0x0102_0304_0506_0708)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_boolean_single_user_path() {
|
|
// Per module doc / api-notes.md: single-user secured writes use
|
|
// currentUserToken == verifierUserToken.
|
|
let h = sample_handle();
|
|
let body = encode(
|
|
&h,
|
|
&WriteValue::Boolean(true),
|
|
TOKEN_A,
|
|
TOKEN_A, // same token both slots
|
|
"Solo",
|
|
1_700_000_000_000_000_000_i64,
|
|
1,
|
|
0x1234,
|
|
)
|
|
.unwrap();
|
|
let decoded = decode(&body).unwrap();
|
|
assert_eq!(decoded.current_user_token, decoded.verifier_user_token);
|
|
assert_eq!(decoded.current_user_token, TOKEN_A);
|
|
assert_eq!(decoded.client_name, "Solo");
|
|
assert_eq!(decoded.inner.value, WriteValue::Boolean(true));
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_with_empty_client_name() {
|
|
// Empty string still emits a 2-byte NUL terminator
|
|
// (`Encoding.Unicode.GetBytes("" + '\0')`).
|
|
let h = sample_handle();
|
|
let body = encode(&h, &WriteValue::Int32(0), TOKEN_A, TOKEN_B, "", 0, 1, 0).unwrap();
|
|
let decoded = decode(&body).unwrap();
|
|
assert_eq!(decoded.client_name, "");
|
|
assert_eq!(decoded.inner.value, WriteValue::Int32(0));
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_with_populated_client_name() {
|
|
let h = sample_handle();
|
|
let body = encode(
|
|
&h,
|
|
&WriteValue::Int32(42),
|
|
TOKEN_A,
|
|
TOKEN_B,
|
|
"Operator-Console-1",
|
|
0,
|
|
7,
|
|
0xABCD,
|
|
)
|
|
.unwrap();
|
|
let decoded = decode(&body).unwrap();
|
|
assert_eq!(decoded.client_name, "Operator-Console-1");
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_string_value() {
|
|
let h = sample_handle();
|
|
let body = encode(
|
|
&h,
|
|
&WriteValue::String("hello".to_string()),
|
|
TOKEN_A,
|
|
TOKEN_B,
|
|
"client",
|
|
0x1122_3344_5566_7788_i64,
|
|
3,
|
|
0xFEED,
|
|
)
|
|
.unwrap();
|
|
let decoded = decode(&body).unwrap();
|
|
assert_eq!(decoded.inner.value, WriteValue::String("hello".to_string()));
|
|
assert_eq!(
|
|
decoded.inner.timestamp_filetime,
|
|
Some(0x1122_3344_5566_7788)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_opcode_rejected() {
|
|
// Take a real body and clobber the opcode.
|
|
let h = sample_handle();
|
|
let mut body = encode(&h, &WriteValue::Int32(1), TOKEN_A, TOKEN_B, "x", 0, 1, 0).unwrap();
|
|
body[0] = 0x37;
|
|
let err = decode(&body).unwrap_err();
|
|
assert!(matches!(err, CodecError::UnexpectedOpcode(0x37)));
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_fields_land_at_correct_offsets() {
|
|
// For Int32 the timestamped prefix length is 40 - 8 = 32 bytes.
|
|
// Verify that:
|
|
// body[32..48] = currentUserToken
|
|
// body[48..52] = clientNameLen i32 LE
|
|
// body[52..52+L] = clientNameBytes
|
|
// body[..+16] = verifierUserToken
|
|
// then -1 i16 + clientToken u32 + writeIndex i32
|
|
let h = sample_handle();
|
|
let client_name = "abc"; // 3 chars * 2 + 2 (NUL) = 8 bytes
|
|
let body = encode(
|
|
&h,
|
|
&WriteValue::Int32(7),
|
|
TOKEN_A,
|
|
TOKEN_B,
|
|
client_name,
|
|
0x1111_2222_3333_4444_i64,
|
|
5,
|
|
0xBEEF_CAFE,
|
|
)
|
|
.unwrap();
|
|
|
|
// prefix_length for Int32 timestamped = 32.
|
|
let prefix_length = 32;
|
|
assert_eq!(&body[prefix_length..prefix_length + 16], &TOKEN_A);
|
|
let name_len_offset = prefix_length + 16;
|
|
let name_len = i32::from_le_bytes([
|
|
body[name_len_offset],
|
|
body[name_len_offset + 1],
|
|
body[name_len_offset + 2],
|
|
body[name_len_offset + 3],
|
|
]);
|
|
assert_eq!(name_len, 8);
|
|
let name_offset = name_len_offset + 4;
|
|
let name_bytes = &body[name_offset..name_offset + 8];
|
|
// "abc\0" UTF-16LE LE = 'a' 0 'b' 0 'c' 0 0 0
|
|
assert_eq!(
|
|
name_bytes,
|
|
&[0x61, 0x00, 0x62, 0x00, 0x63, 0x00, 0x00, 0x00]
|
|
);
|
|
|
|
let verifier_offset = name_offset + 8;
|
|
assert_eq!(&body[verifier_offset..verifier_offset + 16], &TOKEN_B);
|
|
|
|
let tail = verifier_offset + 16;
|
|
let leading_i16 = i16::from_le_bytes([body[tail], body[tail + 1]]);
|
|
assert_eq!(leading_i16, -1);
|
|
let client_token = u32::from_le_bytes([
|
|
body[tail + 2],
|
|
body[tail + 3],
|
|
body[tail + 4],
|
|
body[tail + 5],
|
|
]);
|
|
assert_eq!(client_token, 0xBEEF_CAFE);
|
|
let write_index = i32::from_le_bytes([
|
|
body[tail + 6],
|
|
body[tail + 7],
|
|
body[tail + 8],
|
|
body[tail + 9],
|
|
]);
|
|
assert_eq!(write_index, 5);
|
|
|
|
// Total body length: prefix(32) + 16 + 4 + 8 + 16 + 2 + 4 + 4 = 86
|
|
assert_eq!(body.len(), 86);
|
|
}
|
|
|
|
#[test]
|
|
fn short_buffer_rejected() {
|
|
let err = decode(&[0x38u8; 4]).unwrap_err();
|
|
assert!(matches!(err, CodecError::ShortRead { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_buffer_rejected() {
|
|
let err = decode(&[]).unwrap_err();
|
|
assert!(matches!(err, CodecError::ShortRead { .. }));
|
|
}
|
|
}
|