Channel Binding with HMAC: Preventing Relay-Level MITM Attacks
TermOnMac uses X25519 ECDH for key exchange, but the key exchange happens through a relay server that forwards the public keys. A sufficiently sophisticated attacker who controls the relay could substitute the public keys — accepting the ECDH with each device separately, effectively becoming a man-in-the-middle.
The identity key TOFU check prevents a new relay from impersonating the Mac on reconnect (the public key wouldn’t match what was stored). But it doesn’t prevent the relay from substituting the ephemeral keys on an initial connection, since those are new each time.
The channel binding HMAC closes this gap.
What Channel Binding Means
Channel binding is a cryptographic technique that ties an authentication proof to a specific communication channel. Here, the “channel” is the specific set of ephemeral keys used in this connection. A valid HMAC proves that both devices agree on which ephemeral keys are in use — and that the keys haven’t been substituted.
The HMAC Construction
// mac_agent/Sources/RemoteDevCore/SessionCrypto.swift
/// Compute challenge-response HMAC with channel binding.
/// When both ephemeral keys are present, they are sorted and appended to the nonce
/// to bind the HMAC to the specific ECDH ephemeral keys, preventing a relay MITM
/// from substituting ephemeral keys while passing TOFU checks on identity keys.
public static func challengeHMAC(nonce: Data, roomSecret: String,
localEphemeralKey: String = "",
peerEphemeralKey: String = "") -> Data {
var payload = nonce
if !localEphemeralKey.isEmpty && !peerEphemeralKey.isEmpty {
let sorted = [localEphemeralKey, peerEphemeralKey].sorted()
payload.append(Data(sorted.joined().utf8))
}
return hmacSHA256(data: payload, key: Data(roomSecret.utf8))
}
The HMAC input is: nonce || sorted([localEphemeralKey, peerEphemeralKey]).joined()
The HMAC key is the room secret.
Why This Works
The relay never has the room secret. It only has SHA-256(roomSecret). Without the actual secret, it cannot produce a valid HMAC over any payload.
An attack scenario:
- Relay intercepts
register_roomfrom Mac with ephemeral keyE_mac - Relay generates its own ephemeral key
E_relayand sends it to iPhone as if it wereE_mac - Relay intercepts
join_roomfrom iPhone with ephemeral keyE_ios - Relay generates another key
E_relay2and sends it to Mac as if it wereE_ios
Now the relay has full ECDH with both devices. However, when the challenge step runs:
- Mac computes HMAC over
(nonce, E_mac, E_relay2)— using the real Mac ephemeral key and what it thinks is the iOS ephemeral key - iPhone computes HMAC over
(nonce, E_ios, E_relay)— using the real iOS ephemeral key and what it thinks is the Mac ephemeral key
Both HMACs include the real room secret. But they were computed over different ephemeral key sets. The HMAC that Mac sends to iPhone (to prove identity) won’t match what iPhone expects, because iPhone’s HMAC includes E_ios and E_relay, while Mac’s includes E_mac and E_relay2.
For the attack to succeed, the relay would need to:
- Compute a valid HMAC for both parties — which requires
roomSecret - Know
roomSecret— which it cannot derive fromSHA-256(roomSecret)
Sorted Keys for Order Independence
Both devices sort the two ephemeral keys lexicographically before concatenating:
let sorted = [localEphemeralKey, peerEphemeralKey].sorted()
payload.append(Data(sorted.joined().utf8))
Without sorting, “Mac’s HMAC” and “iPhone’s HMAC” would use keys in different orders ([E_mac, E_ios] vs [E_ios, E_mac]) and the HMACs would never match even in the legitimate case.
Room Secret as HMAC Key
The room secret serves two purposes in the protocol:
- Authorization:
secret_hashlets the relay verify that a joining client is authorized - Authentication: the full secret is the HMAC key in channel binding, verifying that both devices actually possess the secret (not just its hash)
The relay’s knowledge of SHA-256(roomSecret) provides no advantage for forging HMACs, since HMAC-SHA256 with a 256-bit key requires the preimage.