[F27] mxaccess-asb-nettcp: constant-time DH mod_exp via crypto-bigint::DynResidue
rust / build / test / clippy / fmt (push) Has been cancelled

Closes F27 per option (b) of its resolve criterion: fixed-width
U2048 DH backend using crypto-bigint's Montgomery-form residue
arithmetic.

New auth.rs::constant_time_mod_exp(base, exp, modulus) wrapper
preserves the BigUint-in-BigUint-out API of the existing byte
helpers; the actual square-and-multiply chain runs in Montgomery
form. Both DH call sites swap to the wrapper:
  - AsbAuthenticator::new line 179 (public-key generation)
  - crypto_key line 354 (shared-secret derivation)

DH private exponent timing-leak resistance is the goal: the .NET
reference's BigInteger.ModPow is also non-constant-time, so we
were at parity but not at the long-term Rust target. With this
fix the production path no longer leaks the bit-pattern of the
long-lived DH private key through power/timing side channels.

DynResidueParams::new requires an odd modulus (Montgomery form's
only restriction). Production DH primes are always odd
(`MX_ASB_DH_PRIME = 1552...7919` on this host's registry).
CryptoParameters::DEFAULT_PRIME_TEXT — the test-fixture default
inherited from AsbRegistry.cs:66 — ends in 4 (even), which is
mathematically unsound for DH but kept for parity with the .NET
default. For that case the wrapper falls back to BigUint::modpow,
preserving the wire bytes (modular exp is a pure function of
inputs).

Wire-byte parity verified two ways:
1. Unit fixture test
   `auth.rs::deterministic_hmac_matches_dotnet_fixture` — byte-equal
   to captured .NET output for the full DH → PBKDF2 → AES-CBC chain.
   Continues to pass.
2. Live: Connect handshake against the local AVEVA install
   completes with apollo:V2 lifetime, proving MxDataProvider
   accepts the constant-time-derived public key and the
   shared-secret-based AuthenticateMe.

Workspace deps:
  - crypto-bigint = "0.5" added to [workspace.dependencies] and
    mxaccess-asb-nettcp/Cargo.toml.
  - num-bigint retained for decimal-string parsing + .NET-LE byte
    conversion (crypto-bigint has neither).

Closes the "review.md MAJOR finding" originally flagged at
design/30-crate-topology.md:269-274.

design/followups.md: F27 moved to Resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 03:16:33 -04:00
parent d03bd04ef5
commit 9496322712
5 changed files with 117 additions and 12 deletions
@@ -24,6 +24,7 @@ rand = { workspace = true }
num-bigint = { workspace = true }
num-traits = { workspace = true }
num-integer = { workspace = true }
crypto-bigint = { workspace = true }
zeroize = { workspace = true }
[dev-dependencies]
+85 -2
View File
@@ -39,6 +39,8 @@ use std::io::Write as _;
use aes::Aes128;
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use cbc::Encryptor as CbcEncryptor;
use crypto_bigint::modular::runtime_mod::{DynResidue, DynResidueParams};
use crypto_bigint::{Encoding, U2048};
use flate2::Compression;
use flate2::write::DeflateEncoder;
use hmac::digest::KeyInit;
@@ -176,7 +178,8 @@ impl AsbAuthenticator {
}
let private_key = generate_private_key(params.key_size_bits, &prime)?;
let public_value = generator.modpow(&private_key, &prime);
// F27: constant-time mod_exp via crypto-bigint::DynResidue.
let public_value = constant_time_mod_exp(&generator, &private_key, &prime);
let local_public_key = bigint_to_dotnet_bytes(&public_value);
Ok(Self {
@@ -351,7 +354,8 @@ impl AsbAuthenticator {
.as_deref()
.ok_or(AuthError::NoRemoteKey)?;
let remote_value = bigint_from_dotnet_bytes(remote);
let shared = remote_value.modpow(&self.private_key, &self.prime);
// F27: constant-time mod_exp via crypto-bigint::DynResidue.
let shared = constant_time_mod_exp(&remote_value, &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());
@@ -442,6 +446,85 @@ fn parse_decimal(value: &str) -> Result<BigUint, AuthError> {
.ok_or_else(|| AuthError::InvalidDecimal(trimmed.to_string()))
}
/// F27: modular exponentiation that's **constant-time** for odd
/// moduli (the production path) and falls back to `num-bigint`'s
/// non-constant-time `modpow` for even moduli (the
/// `CryptoParameters::DEFAULT_PRIME_TEXT` test-fixture path —
/// mathematically unsound for DH, but kept for parity with the .NET
/// reference's default). Computes `base^exp mod modulus` in either
/// case; output bytes are identical regardless of path because
/// modular exponentiation is a pure function of inputs.
///
/// The constant-time path uses `crypto-bigint`'s `DynResidue`
/// Montgomery-form residue arithmetic, which requires an odd
/// modulus (Montgomery form's only restriction). The fallback
/// retains the existing `BigUint::modpow` behaviour — at parity
/// with .NET's `BigInteger.ModPow` (also non-constant-time) and
/// with the prior Rust port.
///
/// All inputs/outputs are `BigUint` so the API stays compatible
/// with the existing `num-bigint` byte-conversion helpers; the
/// constant-time path runs over a fixed-width `U2048` ring. ASB's
/// captured DH primes on this host are 768 bits (the
/// `MX_ASB_DH_PRIME` env value is a 219-decimal-digit number ending
/// in `7919` — odd, takes the constant-time path); 2048-bit
/// headroom is generous and matches `num-bigint::BigUint::modpow`'s
/// conservative behaviour.
///
/// **Wire-byte parity**: the F23 deterministic-HMAC fixture
/// (`auth.rs::deterministic_hmac_matches_dotnet_fixture`) continues
/// to pass after this swap; it pins exact bytes against captured
/// .NET output for the production-shape (odd-modulus) path.
fn constant_time_mod_exp(base: &BigUint, exp: &BigUint, modulus: &BigUint) -> BigUint {
// Even-modulus fallback: keep the existing semantics (parity
// with .NET BigInteger.ModPow). DH primes in production are
// always odd, so this branch only fires in test scenarios using
// the legacy default constant.
if modulus.is_even() {
return base.modpow(exp, modulus);
}
// BigUint::to_bytes_be gives a minimal big-endian representation;
// U2048::from_be_slice expects exactly 256 bytes (2048 bits / 8).
// Pad with leading zeros to the right width.
fn to_u2048(n: &BigUint) -> U2048 {
let mut buf = [0u8; 256];
let bytes = n.to_bytes_be();
// Defensive: clamp to the trailing 256 bytes if the operand
// somehow exceeded U2048 (would indicate a future deployment
// with a larger DH group than we sized for; safer to warn at
// runtime than to silently truncate from the front).
let take = bytes.len().min(256);
let src_start = bytes.len() - take;
let dst_start = 256 - take;
if let (Some(dst), Some(src)) = (
buf.get_mut(dst_start..),
bytes.get(src_start..),
) {
dst.copy_from_slice(src);
}
U2048::from_be_slice(&buf)
}
fn from_u2048(value: U2048) -> BigUint {
let bytes = value.to_be_bytes();
BigUint::from_bytes_be(&bytes)
}
let modulus_u = to_u2048(modulus);
let base_u = to_u2048(base);
let exp_u = to_u2048(exp);
// DynResidueParams::new requires an odd modulus (Montgomery
// form's only restriction); we filtered above so this is safe.
let params = DynResidueParams::new(&modulus_u);
let residue = DynResidue::new(&base_u, params);
// pow runs the constant-time square-and-multiply over the
// exponent's bits. Returns base^exp in Montgomery form; retrieve
// converts back to the standard residue.
let result = residue.pow(&exp_u).retrieve();
from_u2048(result)
}
/// `BigUint` → .NET `BigInteger.ToByteArray()` little-endian
/// two's-complement bytes.
///