diff --git a/design/followups.md b/design/followups.md index ecb78dc..96a354c 100644 --- a/design/followups.md +++ b/design/followups.md @@ -147,7 +147,16 @@ F25 (`mxaccess-asb` IASBIDataV2 client) and F26 (`mxaccess::Session` over `AsbTr **Registry-driven DH params (commit `f14580e`).** `tools/Get-AsbPassphrase.ps1` exports `MX_ASB_DH_PRIME`, `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM`, `MX_ASB_DH_KEY_SIZE`. The `asb-subscribe` example honours those env vars to override `CryptoParameters::defaults()` (which is the .NET reference's 1024-bit fallback). Each AVEVA install picks its own DH group at provisioning time — typically a 768-bit prime, NOT the default 1024-bit. With the wrong prime, `Connect` succeeds at the byte level but the shared-secret derivation diverges, breaking AuthenticateMe's encrypted ConsumerData verification. Empty registry `hashAlgorithm` maps to `HashAlgorithm::Unrecognised` to match `AsbSystemAuthenticator.CreateHmac:84-93` semantics where empty + `forceHmac=true` falls through to HMAC-SHA1. -**Remaining live blocker (commit `fd38189`).** With canonical XML byte-equal to .NET's AND DH params from the registry, AuthenticateMe still produces `dispatcher/fault` InternalServiceFault. `MX_ASB_TRACE_DERIVE`-gated diagnostic traces in both the Rust authenticator and the .NET reference confirm: crypto_key length matches (176 bytes = 96-byte shared secret + 80-byte passphrase); passphrase bytes [96..176] of the crypto_key are identical between Rust and .NET (same registry source, same UTF-8 encoding). The shared-secret prefix [0..96] differs per session (random DH), but should round-trip correctly with the server. Hypothesis: at least one more byte-level mismatch remains — could be (a) HMAC-SHA1 computation differs at the engine level (test against a fixed-input vector); (b) AES-CBC PKCS7 differs; (c) something about how `CryptoStream.Dispose` flushes vs. our `cbc::Encryptor`; (d) a subtle XmlSerializer behaviour for live (vs. fixture) inputs that the empty-MAC fixture didn't surface. Next iteration: add a deterministic-input HMAC unit test against a captured `(crypto_key, xml_bytes, hmac_bytes)` triple from the .NET probe to localise the discrepancy without dependence on session randomness. +**Remaining live blocker (commit `fd38189`).** With canonical XML byte-equal to .NET's AND DH params from the registry, AuthenticateMe still produces `dispatcher/fault` InternalServiceFault. `MX_ASB_TRACE_DERIVE`-gated diagnostic traces in both the Rust authenticator and the .NET reference confirm: crypto_key length matches (176 bytes = 96-byte shared secret + 80-byte passphrase); passphrase bytes [96..176] of the crypto_key are identical between Rust and .NET (same registry source, same UTF-8 encoding). The shared-secret prefix [0..96] differs per session (random DH), but should round-trip correctly with the server. + +**Crypto stack ruled out** (commit ``). Deterministic-HMAC fixture test (`auth.rs::tests::deterministic_hmac_matches_dotnet_fixture`) takes pinned inputs (passphrase, prime, generator, private-key bytes, remote-pub bytes, message number, connection ID, AES IV, consumer-data + IV) and asserts byte-equality of each step: +1. `shared = remote_pub^private_key mod prime` — ✅ matches .NET +2. `crypto_key = shared || passphrase_utf8` — ✅ matches .NET +3. `hmac = HMAC-SHA1(crypto_key, xml_utf8)` — ✅ matches .NET (HMACSHA1) +4. `aes_key = PBKDF2-SHA1(base64(crypto_key), "ArchestrAService", 1000, 16)` — ✅ matches .NET (Rfc2898DeriveBytes.Pbkdf2) +5. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, PKCS7)` — ✅ matches .NET (System.Security.Cryptography.Aes) + +The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/MxAsbClient.Probe/Program.cs:166-296`), saved at `crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv`. With all 5 crypto steps proven byte-equal to .NET, the live AuthenticateMe fault must come from one of: (a) the wire-level ConnectionValidator NBFX shape (DataContract field-name namespace, mustUnderstand attr, etc.), (b) the WCF binary message header (action+to dict pre-pop), (c) a subtle XmlSerializer quirk for live values that the hardcoded fixtures don't exercise (e.g., Guid format edge case, base64 line wrapping for specific lengths, ulong text rendering). Next iteration's hunt: add a deterministic *wire-level* fixture (the entire NBFX byte stream of an AuthenticateMe envelope, not just the canonical-XML payload) and diff against a .NET probe capture for the same inputs. ### F29 — Align `mxaccess-asb-nettcp::nbfs` static dictionary ids with canonical `[MC-NBFS]` table **Severity:** P2 — diagnostic-only today; blocks future fault-body decoding. diff --git a/rust/crates/mxaccess-asb-nettcp/src/auth.rs b/rust/crates/mxaccess-asb-nettcp/src/auth.rs index 879bca2..e240df8 100644 --- a/rust/crates/mxaccess-asb-nettcp/src/auth.rs +++ b/rust/crates/mxaccess-asb-nettcp/src/auth.rs @@ -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::>(&crypto_key, &xml_bytes); + assert_eq!( + hex::encode_upper(&actual_hmac), + *expected_hmac_hex, + "HMAC-SHA1 output diverges — Rust hmac::Hmac 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::( + 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 { + 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 { + 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 + } } diff --git a/rust/crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/README.md b/rust/crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/README.md new file mode 100644 index 0000000..f20d6c0 --- /dev/null +++ b/rust/crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/README.md @@ -0,0 +1,44 @@ +# Deterministic HMAC fixture + +Pinned input/output triple for the `AsbSystemAuthenticator.Sign` +crypto path, captured from the .NET reference. Used by the Rust +parity test in `crates/mxaccess-asb-nettcp/tests/deterministic_hmac.rs` +to assert byte-equality of crypto_key derivation, canonical XML +emission, HMAC-SHA1, PBKDF2-SHA1 AES key derivation, and AES-CBC +encryption — independent of session randomness (DH private key, +remote public key, and AES IV are all pinned to deterministic values +so a single `cargo test` run can reproduce the .NET output). + +## Capture procedure + +```powershell +dotnet run --project src\MxAsbClient.Probe -c Release -- --dump-deterministic-hmac > capture.txt +``` + +The probe's `--dump-deterministic-hmac` flag (added 2026-05-05) +inlines the per-step decomposition of `Sign` (`AsbSystemAuthenticator +.cs:62-82`): + +1. `shared = remote_pub^private_key mod prime` (.NET `BigInteger.ModPow`) +2. `crypto_key = shared || passphrase_utf8` +3. `xml = AuthenticateMe.ToXml()` with empty MAC + IV +4. `hmac = HMAC-SHA1(crypto_key, utf8(xml))` +5. `aes_key = PBKDF2-SHA1(base64(crypto_key), "ArchestrAService", 1000, 16)` +6. `encrypted_mac = AES-CBC(aes_key, iv=zeros, hmac, padding=PKCS7)` + +Step 6 uses an all-zero IV to make the test reproducible — the real +wire path uses a random IV per call, but the Rust test bypasses the +random IV path by calling the AES primitive directly with the same +zero IV. + +## File format + +Plain-ASCII `key=value` lines, one per line. Hex values are +upper-case (matching .NET's `Convert.ToHexString`). The `xml_utf8_b64` +field encodes the canonical XML as base64 of the UTF-8 bytes. + +## Files + +- `authenticate-me.kv` — fixture for the `AuthenticateMe` shape with + the `[XmlType(Namespace="http://asb.contracts.data/20111111")]` + ConsumerAuthenticationData wrapper. diff --git a/rust/crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv b/rust/crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv new file mode 100644 index 0000000..27b4765 --- /dev/null +++ b/rust/crates/mxaccess-asb-nettcp/tests/fixtures/deterministic-hmac/authenticate-me.kv @@ -0,0 +1,21 @@ +# deterministic-hmac fixture (.NET reference output) +prime_decimal=179769313486231590770839156793787453197860296048756011706444423684197180216158519368947833795864925541502180565485980503646440548199239100050792877003355816639229553136239076508735759914822574862575007425302077447712589550957937778424442426617334727629299387668709205606050270810842907692932019128194 +generator=22 +private_key_hex=0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F2000 +remote_pub_hex=0D141B222930373E454C535A61686F767D848B9299A0A7AEB5BCC3CAD1D8DFE6EDF4FB020910171E252C333A41484F565D646B727980878E959CA3AAB1B8BFC6CDD4DBE2E9F0F7FE050C131A21282F363D444B525960676E757C838A91989FA6ADB4BBC2C9D0D7DEE5ECF3FA01080F161D242B323940474E555C636A71787F7F +passphrase=deterministic-hmac-fixture-passphrase-rust-vs-dotnet +connection_id=8cba964a-74c1-ef74-f6aa-761b3540191b +message_number=42 +consumer_data_hex=070A0D101316191C1F2225282B2E3134373A3D404346494C4F5255585B5E6164676A6D707376797C7F8285888B8E9194979A9DA0A3A6A9ACAFB2B5B8BBBEC1C4C7CACDD0D3D6D9DCDFE2E5E8EBEEF1F4F7FAFD000306090C0F1215181B1E2124272A2D303336393C3F4245484B4E5154575A5D606366696C6F7275787B7E8184878A8D909396999C9FA2A5A8ABAEB1B4B7BABDC0C3C6C9CCCFD2D5D8DBDEE1E4E7EAEDF0F3F6F9FCFF0205080B0E1114171A1D202326292C2F3235383B3E4144474A4D505356595C5F6265686B6E7174 +consumer_iv_hex=05101B26313C47525D68737E89949FAA +aes_iv_hex=00000000000000000000000000000000 +shared_secret_hex=05F8563585C58EF5AF2A2DFFD4BC73FCD043FEFB470ED66EE07D5D9882DB27A478C58B6B857B300409064669C42C1C84F3457E6C0C4A00E578DF90DC817CB8BBDFE866F3EE9820E3BF8C772827C5E3BAE164553B4C65EC927865D7AA4F2AC5124F5F85B49A7C460F5BA06B4651A580D935BE1CFA577A9B2ED47980D200 +shared_secret_len=125 +crypto_key_hex=05F8563585C58EF5AF2A2DFFD4BC73FCD043FEFB470ED66EE07D5D9882DB27A478C58B6B857B300409064669C42C1C84F3457E6C0C4A00E578DF90DC817CB8BBDFE866F3EE9820E3BF8C772827C5E3BAE164553B4C65EC927865D7AA4F2AC5124F5F85B49A7C460F5BA06B4651A580D935BE1CFA577A9B2ED47980D20064657465726D696E69737469632D686D61632D666978747572652D706173737068726173652D727573742D76732D646F746E6574 +crypto_key_len=177 +xml_utf8_len=1136 +xml_utf8_b64=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTE2Ij8+DQo8QXV0aGVudGljYXRlTWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM6eHNkPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM9InVybjppbnZlbnN5cy5zY2hlbWFzIj4NCiAgPENvbm5lY3Rpb25WYWxpZGF0b3I+DQogICAgPENvbm5lY3Rpb25JZCB4bWxucz0iaHR0cDovL2FzYi5jb250cmFjdHMuZGF0YS8yMDExMTExMSI+OGNiYTk2NGEtNzRjMS1lZjc0LWY2YWEtNzYxYjM1NDAxOTFiPC9Db25uZWN0aW9uSWQ+DQogICAgPE1lc3NhZ2VOdW1iZXIgeG1sbnM9Imh0dHA6Ly9hc2IuY29udHJhY3RzLmRhdGEvMjAxMTExMTEiPjQyPC9NZXNzYWdlTnVtYmVyPg0KICAgIDxNZXNzYWdlQXV0aGVudGljYXRpb25Db2RlIHhtbG5zPSJodHRwOi8vYXNiLmNvbnRyYWN0cy5kYXRhLzIwMTExMTExIiAvPg0KICAgIDxTaWduYXR1cmVJbml0aWFsaXphdGlvblZlY3RvciB4bWxucz0iaHR0cDovL2FzYi5jb250cmFjdHMuZGF0YS8yMDExMTExMSIgLz4NCiAgPC9Db25uZWN0aW9uVmFsaWRhdG9yPg0KICA8Q29uc3VtZXJBdXRoZW50aWNhdGlvbkRhdGE+DQogICAgPERhdGEgeG1sbnM9Imh0dHA6Ly9hc2IuY29udHJhY3RzLmRhdGEvMjAxMTExMTEiPkJ3b05FQk1XR1J3ZklpVW9LeTR4TkRjNlBVQkRSa2xNVDFKVldGdGVZV1JuYW0xd2MzWjVmSCtDaFlpTGpwR1VsNXFkb0tPbXFheXZzclc0dTc3QnhNZkt6ZERUMXRuYzMrTGw2T3Z1OGZUMyt2MEFBd1lKREE4U0ZSZ2JIaUVrSnlvdE1ETTJPVHcvUWtWSVMwNVJWRmRhWFdCalptbHNiM0oxZUh0K2dZU0hpbzJRazVhWm5KK2lwYWlycnJHMHQ3cTl3TVBHeWN6UDB0WFkyOTdoNU9mcTdmRHo5dm44L3dJRkNBc09FUlFYR2gwZ0l5WXBMQzh5TlRnN1BrRkVSMHBOVUZOV1dWeGZZbVZvYTI1eGRBPT08L0RhdGE+DQogICAgPEluaXRpYWxpemF0aW9uVmVjdG9yIHhtbG5zPSJodHRwOi8vYXNiLmNvbnRyYWN0cy5kYXRhLzIwMTExMTExIj5CUkFiSmpFOFIxSmRhSE4raVpTZnFnPT08L0luaXRpYWxpemF0aW9uVmVjdG9yPg0KICA8L0NvbnN1bWVyQXV0aGVudGljYXRpb25EYXRhPg0KPC9BdXRoZW50aWNhdGVNZT4= +hmac_sha1_hex=4EDF6AF60E72C7026D2F5231F0E91FCEFC30E3D6 +aes_key_hex=E5532AC4BFC5628B20B0ED307B2C88AC +encrypted_mac_hex=2E6A290397F688F2AE97B421184F44359C05FC59891BFA49BFD068C41EF9B42B +encrypted_mac_len=32 diff --git a/src/MxAsbClient.Probe/Program.cs b/src/MxAsbClient.Probe/Program.cs index eee2859..2b89abd 100644 --- a/src/MxAsbClient.Probe/Program.cs +++ b/src/MxAsbClient.Probe/Program.cs @@ -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