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.