Cryptography

X25519 Key Exchange Without a Central Key Server: How QR Pairing Works

TermOnMac pairs an iPhone with a Mac using a QR code scan. After pairing, all terminal traffic is encrypted with a session key that exists only on the two devices — never on the relay server.

Two Types of Key Pairs

The Mac generates and maintains two distinct X25519 key pairs:

Identity key (persistent) Generated once, stored in ~/.config/termonmac/. This key identifies the Mac across reconnections. The relay stores the public key and uses it for TOFU (Trust On First Use): if the identity key changes on reconnect, the relay rejects the connection.

Ephemeral key (per-connection) Generated fresh on every connection. This key is used for the actual ECDH that produces the session key. Because it changes per-connection, past session keys cannot be derived even if a future ephemeral private key is compromised.

Both public keys are sent to the relay in register_room:

// relay_server/src/types.ts
export interface RegisterRoomMessage {
  type: "register_room";
  room_id: string;
  secret_hash: string;    // SHA-256(roomSecret) — relay verifies authorization, cannot reverse
  public_key: string;     // Persistent identity key — TOFU verification on reconnect
  session_nonce?: string; // 32 random bytes — unique per connection, part of HKDF salt
  ephemeral_key?: string; // Per-connection ephemeral key — used for ECDH, not stored long-term
}

The QR Code

The QR code contains room_id and room_secret in plaintext. The room secret authorizes access to the room — without it, a client cannot pass the relay’s secret_hash verification.

The secret is never sent to the relay directly. The relay only stores SHA-256(roomSecret). When iOS joins, it submits secret_hash; the relay verifies the hash matches what was registered.

Key Exchange Flow

Mac                  Relay (Room DO)              iPhone
 |                        |                          |
 |── register_room ──────►|                          |
 |   {public_key,         | stores: mac_public_key   |
 |    ephemeral_key,       |   room state in DO       |
 |    session_nonce}       |   storage                |
 |                        |                          |
 |                        |◄── join_room ────────────|
 |                        |    {public_key,           |
 |                        |     ephemeral_key,        |
 |                        |     session_nonce}        |
 |                        |                          |
 |◄─ peer_joined ─────────|── peer_joined ──────────►|
 |   {ios public_key,     |   {mac public_key,        |
 |    ios ephemeral_key,   |    mac ephemeral_key,     |
 |    ios nonce}           |    mac nonce}             |
 |                        |                          |
 | ECDH(mac_ephemeral_priv, ios_ephemeral_pub)        |
 | HKDF → sessionKey       |   ECDH(ios_ephemeral_priv, mac_ephemeral_pub)
 |                        |   HKDF → sessionKey      |
 |                        |                          |
 |── relay {encrypted} ──►|── relay {encrypted} ────►|

ECDH in Swift

Using Apple’s CryptoKit:

// mac_agent/Sources/RemoteDevCore/SessionCrypto.swift
public func deriveSessionKeyEphemeral(
    ephemeralPrivateKey: Curve25519.KeyAgreement.PrivateKey,
    peerEphemeralKeyBase64: String,
    localNonce: String,
    remoteNonce: String
) throws {
    guard let peerKeyData = Data(base64Encoded: peerEphemeralKeyBase64) else {
        throw CryptoError.invalidKey
    }
    let peerKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerKeyData)
    let shared = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: peerKey)
    let sortedNonces = [localNonce, remoteNonce].sorted()
    let salt = Data("remotedev-v2".utf8) + Data(sortedNonces.joined().utf8)
    self.sessionKey = shared.hkdfDerivedSymmetricKey(
        using: SHA256.self,
        salt: salt,
        sharedInfo: Data(),
        outputByteCount: 32
    )
}

Note: the ECDH uses the ephemeral private key, not the identity key. The identity key is used only for TOFU verification at the relay level, not for session key derivation.

TOFU: Verifying Mac Identity on Reconnect

When the Mac reconnects to the same room, the relay checks that the public_key (identity key) matches what was registered the first time:

// relay_server/src/types.ts
export interface RoomState {
  mac_public_key: string;  // stored on first register, compared on every subsequent register
  secret_hash: string;
  // ...
}

If the identity key changes — for example, if someone else tries to hijack the room by registering with a different key — the relay rejects the registration.

Channel Binding

There’s one more layer: a challenge HMAC that binds the session to the specific ephemeral keys in use. Both devices compute:

// mac_agent/Sources/RemoteDevCore/SessionCrypto.swift
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 includes both ephemeral public keys (sorted). If the relay substituted an ephemeral key — attempting a man-in-the-middle attack while passing TOFU — the HMAC would fail, because the room secret (required to produce a valid HMAC) is never sent to the relay. See Channel Binding with HMAC for detail.