[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:
Joseph Doherty
2026-05-05 19:12:17 -04:00
parent 42ac10a88f
commit ce27b63010
5 changed files with 388 additions and 1 deletions
+130
View File
@@ -166,6 +166,136 @@ if (args.Any(arg => arg.Equals("--dump-signed-xml", StringComparison.OrdinalIgno
return;
}
// `--dump-deterministic-hmac` runs the AuthenticateMe sign path with
// FIXED inputs end-to-end (no randomness): pinned passphrase, prime,
// generator, private-key bytes, remote-pub bytes, connection ID,
// message number, AES IV, and consumer-data/IV bytes. Output is the
// resulting crypto_key, AES key, canonical XML, HMAC-SHA1, and
// AES-CBC-encrypted MAC. The Rust port uses these as a fixture for a
// byte-equality unit test that localises any HMAC/AES/derivation
// divergence vs the .NET reference without depending on session
// randomness. Mirrors the per-step decomposition of `AsbSystemAuthent
// icator.Sign` (`AsbSystemAuthenticator.cs:62-82`) but inlines the
// math so we control every byte of input.
if (args.Any(arg => arg.Equals("--dump-deterministic-hmac", StringComparison.OrdinalIgnoreCase)))
{
System.Numerics.BigInteger prime = System.Numerics.BigInteger.Parse(AsbSolutionCryptoParameters.DefaultPrimeText);
System.Numerics.BigInteger generator = 22;
// 33 bytes: 0x01..0x20 with trailing 0x00 sign byte. Mirrors the
// shape `AsbSystemAuthenticator.CreatePrivateKey` produces.
byte[] privateKeyBytes = new byte[33];
for (int i = 0; i < 32; i++) { privateKeyBytes[i] = (byte)(i + 1); }
privateKeyBytes[32] = 0x00;
// Remote public key — 128 bytes (1024-bit), high bit clear so
// .NET's BigInteger LE-two's-complement reads it as positive
// without a sign-byte fix-up.
byte[] remotePub = new byte[128];
for (int i = 0; i < 127; i++) { remotePub[i] = (byte)((i * 7 + 13) & 0xFF); }
remotePub[127] = 0x7F;
string passphrase = "deterministic-hmac-fixture-passphrase-rust-vs-dotnet";
Guid connectionId = Guid.Parse("8cba964a-74c1-ef74-f6aa-761b3540191b");
ulong messageNumber = 42;
// ConsumerAuthenticationData payload. Encrypted bytes are opaque
// to the HMAC test (they get base64-embedded in the XML and
// signed); use deterministic bytes 0x80..0xFF + 0x00..0x4F (208
// bytes — same as a real AuthenticateMe under a 768-bit prime).
byte[] consumerData = new byte[208];
for (int i = 0; i < 208; i++) { consumerData[i] = (byte)((i * 3 + 7) & 0xFF); }
byte[] consumerIv = new byte[16];
for (int i = 0; i < 16; i++) { consumerIv[i] = (byte)((i * 11 + 5) & 0xFF); }
// Deterministic AES IV for encrypting the HMAC. We pick all-zeros
// so the Rust test can reproduce without a random-IV injection
// hack. (The real wire path uses a random IV per call; here we
// bypass that to make the test reproducible.)
byte[] aesIv = new byte[16];
// ---- crypto_key = shared_secret || passphrase_utf8 ----------
System.Numerics.BigInteger sharedValue = System.Numerics.BigInteger.ModPow(
new System.Numerics.BigInteger(remotePub),
new System.Numerics.BigInteger(privateKeyBytes),
prime);
byte[] shared = sharedValue.ToByteArray();
byte[] cryptoKey = [.. shared, .. System.Text.Encoding.UTF8.GetBytes(passphrase)];
// ---- canonical XML (empty MAC + IV) -------------------------
AuthenticateMe req = new()
{
ConnectionValidator = new()
{
ConnectionId = connectionId,
MessageNumber = messageNumber,
MessageAuthenticationCode = [],
SignatureInitializationVector = [],
},
ConsumerAuthenticationData = new AuthenticationData
{
Data = consumerData,
InitializationVector = consumerIv,
},
};
string xmlText = req.ToXml();
byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(xmlText);
// ---- HMAC-SHA1(crypto_key, xml_utf8) ------------------------
using System.Security.Cryptography.HMACSHA1 hmac = new(cryptoKey);
byte[] hash = hmac.ComputeHash(xmlBytes);
// ---- AES key = PBKDF2-SHA1(base64(crypto_key), salt, 1000) --
byte[] salt = System.Text.Encoding.ASCII.GetBytes("ArchestrAService");
byte[] aesKey = System.Security.Cryptography.Rfc2898DeriveBytes.Pbkdf2(
Convert.ToBase64String(cryptoKey),
salt,
iterations: 1000,
System.Security.Cryptography.HashAlgorithmName.SHA1,
outputLength: 16);
// ---- AES-CBC encrypt(hash) with fixed IV --------------------
byte[] encryptedMac;
using (System.Security.Cryptography.Aes aes = System.Security.Cryptography.Aes.Create())
{
aes.Key = aesKey;
aes.IV = aesIv;
// CBC mode, PKCS7 padding (defaults).
using System.IO.MemoryStream ms = new();
using (System.Security.Cryptography.CryptoStream cs = new(
ms,
aes.CreateEncryptor(),
System.Security.Cryptography.CryptoStreamMode.Write))
{
cs.Write(hash, 0, hash.Length);
}
encryptedMac = ms.ToArray();
}
Console.WriteLine("# deterministic-hmac fixture (.NET reference output)");
Console.WriteLine($"prime_decimal={prime}");
Console.WriteLine($"generator={generator}");
Console.WriteLine($"private_key_hex={Convert.ToHexString(privateKeyBytes)}");
Console.WriteLine($"remote_pub_hex={Convert.ToHexString(remotePub)}");
Console.WriteLine($"passphrase={passphrase}");
Console.WriteLine($"connection_id={connectionId:D}");
Console.WriteLine($"message_number={messageNumber}");
Console.WriteLine($"consumer_data_hex={Convert.ToHexString(consumerData)}");
Console.WriteLine($"consumer_iv_hex={Convert.ToHexString(consumerIv)}");
Console.WriteLine($"aes_iv_hex={Convert.ToHexString(aesIv)}");
Console.WriteLine($"shared_secret_hex={Convert.ToHexString(shared)}");
Console.WriteLine($"shared_secret_len={shared.Length}");
Console.WriteLine($"crypto_key_hex={Convert.ToHexString(cryptoKey)}");
Console.WriteLine($"crypto_key_len={cryptoKey.Length}");
Console.WriteLine($"xml_utf8_len={xmlBytes.Length}");
Console.WriteLine($"xml_utf8_b64={Convert.ToBase64String(xmlBytes)}");
Console.WriteLine($"hmac_sha1_hex={Convert.ToHexString(hash)}");
Console.WriteLine($"aes_key_hex={Convert.ToHexString(aesKey)}");
Console.WriteLine($"encrypted_mac_hex={Convert.ToHexString(encryptedMac)}");
Console.WriteLine($"encrypted_mac_len={encryptedMac.Length}");
return;
}
if (probeConnectFailure)
{
try