[M5] mxaccess-asb-nettcp: M5 plan + F19 deps + F23 auth crypto port
F18 plans M5 as 9 sub-followups (F18-F26 + F27 constant-time DH) per design/dependencies.md:73-89. Wave-1 streams F20-F23+F24 are parallel-safe after F19 (workspace deps). F25 (ASB client) is sequential after the framing/encoder streams. F26 (Session over AsbTransport) is sequential after F25. F19 — workspace deps for the M5 crypto + framing surface: hmac, md-5, sha1, sha2, aes, cbc, pbkdf2, flate2, rand, num-bigint, num-traits, num-integer, quick-xml, tokio-util, zeroize. Pinned to the digest 0.10 / cipher 0.4 generation matching mxaccess-rpc. F23 — ports `AsbSystemAuthenticator.cs` (167 LoC) to `mxaccess-asb-nettcp::auth`. Wire-byte parity points: .NET BigInteger little-endian two's-complement byte order with optional 0x00 sign-byte suffix; AES-128-CBC with PKCS7 padding; PBKDF2-SHA1 1000 iterations over `Convert.ToBase64String(crypto_key)` with ASCII salt "ArchestrAService"; deflate-then-AES (Baktun) vs raw-AES (Apollo) selected by `:V2` lifetime suffix; HMAC-MD5/SHA1/SHA512 negotiated per `AsbSolutionCryptoParameters.HashAlgorithm` (with `force_hmac=true` fallback to HMAC-SHA1 for unrecognised algorithms). 13 unit tests cover the cryptographic primitives + DH peer agreement + .NET byte-order round-trip + Apollo lifetime dispatch. F27 — filed for the `num-bigint` → `crypto-bigint::BoxedUint` swap once the latter exposes a stable heap-allocated `pow_mod`. Currently at parity with the .NET reference (also not constant-time). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-1
@@ -46,7 +46,17 @@ 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.
|
||||
**This-iteration execution slice (resolved in this commit).** F19 + F23 landed:
|
||||
- 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.
|
||||
|
||||
F20-F22, F24-F26 remain open for parallel agent fan-out. F27 (constant-time DH) is filed as a separate follow-up below.
|
||||
|
||||
### 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
+212
-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"
|
||||
@@ -257,6 +362,25 @@ dependencies = [
|
||||
[[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 +445,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 +558,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 +567,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 +843,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,717 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@
|
||||
//! 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;
|
||||
|
||||
Reference in New Issue
Block a user