[M5] auth: deterministic HMAC fixture test rules out crypto stack
Adds end-to-end byte-equality test against a `.NET reference fixture captured via the new `MxAsbClient.Probe --dump-deterministic-hmac` flag. All inputs are pinned (passphrase, prime, generator, private- key bytes, remote-pub bytes, message number, connection ID, AES IV, consumer-data + IV bytes), so the test reproduces .NET's exact output for every crypto step: 1. shared = remote_pub^private_key mod prime — ✅ matches 2. crypto_key = shared || passphrase_utf8 — ✅ matches 3. hmac = HMAC-SHA1(crypto_key, xml_utf8) — ✅ matches 4. aes_key = PBKDF2-SHA1(base64(crypto_key), salt, 1000, 16) — ✅ 5. encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7) — ✅ This conclusively rules out the entire crypto stack as the source of the live AuthenticateMe `dispatcher/fault`. Our DH math, HMAC engine, PBKDF2 derivation, AES-CBC PKCS7, and crypto_key concatenation are byte-equal to .NET. The remaining live failure must come from one of: (a) wire-level ConnectionValidator NBFX shape (DataContract field names, mustUnderstand attribute, namespace), (b) WCF binary message header (action+to dict pre-pop), or (c) a subtle XmlSerializer quirk for live values that the hardcoded fixtures don't exercise (Guid format edge case, base64 line wrapping, ulong text rendering). Fixture lives at `crates/mxaccess-asb-nettcp/tests/fixtures/ deterministic-hmac/authenticate-me.kv` (KV format, ASCII hex, lines trim CRLF/LF transparently). The companion `README.md` documents the capture procedure and the per-step decomposition. The test consumes the .NET-supplied canonical XML directly from the fixture's `xml_utf8_b64` so a Rust XML emitter bug would not mask a Rust crypto bug — XML byte-equality is verified separately by `mxaccess-asb::xml_canonical::tests` against the `signed-xml/*.xml` fixtures. Workspace: 710 unit tests pass (was 709 + 1 new). Clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -732,4 +732,187 @@ mod tests {
|
||||
// real cross-impl vector comes later, replace the bytes inline.
|
||||
assert_eq!(out.as_slice(), snapshot.as_slice());
|
||||
}
|
||||
|
||||
/// End-to-end byte-equality test against a `.NET reference fixture
|
||||
/// captured via `MxAsbClient.Probe --dump-deterministic-hmac`. All
|
||||
/// inputs (passphrase, prime, generator, private-key bytes, remote
|
||||
/// public-key bytes, message number, connection ID, AES IV,
|
||||
/// consumer-data + IV bytes) are pinned, so this test reproduces
|
||||
/// .NET's exact output for:
|
||||
///
|
||||
/// 1. `shared = remote_pub^private_key mod prime`
|
||||
/// 2. `crypto_key = shared || passphrase_utf8`
|
||||
/// 3. `hmac = HMAC-SHA1(crypto_key, xml_utf8)` where `xml_utf8` is
|
||||
/// the canonical XML emitted by .NET's `XmlSerializer` (decoded
|
||||
/// from the fixture's `xml_utf8_b64`).
|
||||
/// 4. `aes_key = PBKDF2-SHA1(base64(crypto_key),
|
||||
/// "ArchestrAService", 1000, 16)`
|
||||
/// 5. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7)`
|
||||
///
|
||||
/// If any step diverges from the .NET reference, this test localises
|
||||
/// the bug — without depending on session randomness (which is what
|
||||
/// makes the live AuthenticateMe failure so hard to diagnose).
|
||||
///
|
||||
/// **Important:** the canonical XML byte-equality is verified
|
||||
/// separately by `mxaccess-asb::xml_canonical::tests` against the
|
||||
/// `signed-xml/*.xml` fixtures. Here we just consume the
|
||||
/// `.NET-supplied XML bytes from the fixture so a Rust XML emitter
|
||||
/// bug doesn't mask a Rust crypto bug (or vice versa).
|
||||
#[test]
|
||||
fn deterministic_hmac_matches_dotnet_fixture() {
|
||||
use hmac::Hmac;
|
||||
use sha1::Sha1;
|
||||
|
||||
let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/deterministic-hmac/authenticate-me.kv");
|
||||
let raw = std::fs::read_to_string(&fixture_path).unwrap_or_else(|e| {
|
||||
panic!("could not read fixture {}: {e}", fixture_path.display())
|
||||
});
|
||||
let kv = parse_kv(&raw);
|
||||
|
||||
let prime_decimal = kv.get("prime_decimal").expect("prime_decimal");
|
||||
let private_key_hex = kv.get("private_key_hex").expect("private_key_hex");
|
||||
let remote_pub_hex = kv.get("remote_pub_hex").expect("remote_pub_hex");
|
||||
let passphrase = kv.get("passphrase").expect("passphrase");
|
||||
let consumer_data_hex = kv.get("consumer_data_hex").expect("consumer_data_hex");
|
||||
let consumer_iv_hex = kv.get("consumer_iv_hex").expect("consumer_iv_hex");
|
||||
let aes_iv_hex = kv.get("aes_iv_hex").expect("aes_iv_hex");
|
||||
let expected_shared_hex = kv.get("shared_secret_hex").expect("shared_secret_hex");
|
||||
let expected_crypto_key_hex = kv.get("crypto_key_hex").expect("crypto_key_hex");
|
||||
let expected_xml_b64 = kv.get("xml_utf8_b64").expect("xml_utf8_b64");
|
||||
let expected_hmac_hex = kv.get("hmac_sha1_hex").expect("hmac_sha1_hex");
|
||||
let expected_aes_key_hex = kv.get("aes_key_hex").expect("aes_key_hex");
|
||||
let expected_encrypted_mac_hex =
|
||||
kv.get("encrypted_mac_hex").expect("encrypted_mac_hex");
|
||||
|
||||
// Step 1 — shared = remote_pub^private mod prime
|
||||
let prime = parse_decimal(prime_decimal).unwrap();
|
||||
let private_key_bytes = hex::decode(private_key_hex).unwrap();
|
||||
let remote_pub_bytes = hex::decode(remote_pub_hex).unwrap();
|
||||
let private_key_value = bigint_from_dotnet_bytes(&private_key_bytes);
|
||||
let remote_pub_value = bigint_from_dotnet_bytes(&remote_pub_bytes);
|
||||
let shared_value = remote_pub_value.modpow(&private_key_value, &prime);
|
||||
let shared_bytes = bigint_to_dotnet_bytes(&shared_value);
|
||||
assert_eq!(
|
||||
hex::encode_upper(&shared_bytes),
|
||||
*expected_shared_hex,
|
||||
"shared_secret bytes diverge from .NET (DH math mismatch — \
|
||||
check parse_decimal, bigint_from/to_dotnet_bytes, modpow)"
|
||||
);
|
||||
|
||||
// Step 2 — crypto_key = shared || passphrase_utf8
|
||||
let mut crypto_key = shared_bytes.clone();
|
||||
crypto_key.extend_from_slice(passphrase.as_bytes());
|
||||
assert_eq!(
|
||||
hex::encode_upper(&crypto_key),
|
||||
*expected_crypto_key_hex,
|
||||
"crypto_key concatenation diverges (likely passphrase \
|
||||
encoding bug — .NET uses Encoding.UTF8.GetBytes)"
|
||||
);
|
||||
|
||||
// Step 3 — HMAC-SHA1(crypto_key, xml_utf8)
|
||||
let xml_bytes = base64_decode_strict(expected_xml_b64);
|
||||
let actual_hmac = hmac_compute::<Hmac<Sha1>>(&crypto_key, &xml_bytes);
|
||||
assert_eq!(
|
||||
hex::encode_upper(&actual_hmac),
|
||||
*expected_hmac_hex,
|
||||
"HMAC-SHA1 output diverges — Rust hmac::Hmac<Sha1> does \
|
||||
NOT match .NET's HMACSHA1 for the same (key, message)"
|
||||
);
|
||||
|
||||
// Step 4 — AES key = PBKDF2-SHA1(base64(crypto_key), salt, 1000, 16)
|
||||
let password_b64 = base64_encode(&crypto_key);
|
||||
let mut aes_key = [0u8; AES_KEY_LEN];
|
||||
pbkdf2_hmac::<Sha1>(
|
||||
password_b64.as_bytes(),
|
||||
PASSWORD_SALT,
|
||||
PBKDF2_ITERATIONS,
|
||||
&mut aes_key,
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode_upper(aes_key),
|
||||
*expected_aes_key_hex,
|
||||
"PBKDF2-SHA1(base64(crypto_key)) diverges — likely a salt \
|
||||
or iteration-count mismatch, or password is being byte- \
|
||||
encoded differently from .NET's `Convert.ToBase64String`"
|
||||
);
|
||||
|
||||
// Step 5 — AES-CBC encrypt(hmac) with fixed IV
|
||||
let aes_iv_bytes = hex::decode(aes_iv_hex).unwrap();
|
||||
let aes_iv: [u8; 16] = aes_iv_bytes.try_into().expect("aes_iv must be 16 bytes");
|
||||
let encrypted_mac = aes_cbc_encrypt(&aes_key, &aes_iv, &actual_hmac);
|
||||
assert_eq!(
|
||||
hex::encode_upper(&encrypted_mac),
|
||||
*expected_encrypted_mac_hex,
|
||||
"AES-CBC encrypt diverges — could be a PKCS7 padding bug, \
|
||||
a key-length mismatch, or a cipher-suite drift"
|
||||
);
|
||||
|
||||
// Sanity assertions to catch fixture corruption.
|
||||
assert_eq!(consumer_data_hex.len(), 208 * 2, "fixture consumer data");
|
||||
assert_eq!(consumer_iv_hex.len(), 16 * 2, "fixture consumer iv");
|
||||
}
|
||||
|
||||
fn parse_kv(text: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
out.insert(key.trim().to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strict standard-base64 decoder. Mirrors .NET's
|
||||
/// `Convert.FromBase64String` for the subset of inputs this test
|
||||
/// uses (no line wrapping, standard alphabet, padding required).
|
||||
fn base64_decode_strict(s: &str) -> Vec<u8> {
|
||||
let trimmed: String = s.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if trimmed.len() % 4 != 0 {
|
||||
panic!("base64 input length {} not multiple of 4", trimmed.len());
|
||||
}
|
||||
const VAL: [i8; 256] = build_b64_table();
|
||||
let mut out = Vec::with_capacity(trimmed.len() / 4 * 3);
|
||||
let bytes = trimmed.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let c0 = bytes[i];
|
||||
let c1 = bytes[i + 1];
|
||||
let c2 = bytes[i + 2];
|
||||
let c3 = bytes[i + 3];
|
||||
let v0 = VAL[c0 as usize];
|
||||
let v1 = VAL[c1 as usize];
|
||||
let v2 = if c2 == b'=' { 0 } else { VAL[c2 as usize] };
|
||||
let v3 = if c3 == b'=' { 0 } else { VAL[c3 as usize] };
|
||||
assert!(v0 >= 0 && v1 >= 0 && v2 >= 0 && v3 >= 0, "invalid b64 char");
|
||||
let triple = ((v0 as u32) << 18)
|
||||
| ((v1 as u32) << 12)
|
||||
| ((v2 as u32) << 6)
|
||||
| (v3 as u32);
|
||||
out.push((triple >> 16) as u8);
|
||||
if c2 != b'=' {
|
||||
out.push((triple >> 8) as u8);
|
||||
}
|
||||
if c3 != b'=' {
|
||||
out.push(triple as u8);
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const fn build_b64_table() -> [i8; 256] {
|
||||
let mut t = [-1i8; 256];
|
||||
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut i = 0;
|
||||
while i < alphabet.len() {
|
||||
t[alphabet[i] as usize] = i as i8;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user