Cryptography

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:

  1. Relay intercepts register_room from Mac with ephemeral key E_mac
  2. Relay generates its own ephemeral key E_relay and sends it to iPhone as if it were E_mac
  3. Relay intercepts join_room from iPhone with ephemeral key E_ios
  4. Relay generates another key E_relay2 and sends it to Mac as if it were E_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:

  1. Compute a valid HMAC for both parties — which requires roomSecret
  2. Know roomSecret — which it cannot derive from SHA-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:

  1. Authorization: secret_hash lets the relay verify that a joining client is authorized
  2. 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.