How TermOnMac Works: A Zero-Knowledge Mac Terminal Relay
TermOnMac lets an iPhone control a Mac terminal over the internet without SSH setup or port forwarding. The architecture has three components: an iOS app, a Mac CLI daemon, and a relay server on Cloudflare Workers. All terminal data is end-to-end encrypted between the Mac and iPhone — the relay forwards ciphertext it cannot read.
System Architecture
iPhone App ──WSS──► Cloudflare Room DO ──WSS──► Mac CLI Daemon
(TLS) (TLS)
iOS App: SwiftUI + SwiftTerm emulator
Mac CLI: Swift daemon managing PTY sessions
Relay: Cloudflare Workers + Durable Objects
Connection Setup
Mac side — register_room
The Mac CLI connects to the relay and sends a register_room message:
// relay_server/src/types.ts
export interface RegisterRoomMessage {
type: "register_room";
room_id: string; // UUIDv4, generated on the Mac
secret_hash: string; // SHA-256 hash of room secret (relay verifies, never reverses)
public_key: string; // Base64 X25519 identity key (persistent, used for TOFU)
session_nonce?: string; // Base64 32 random bytes, unique per connection
ephemeral_key?: string; // Base64 X25519 ephemeral key (per-connection, for forward secrecy)
pairing_token_hash?: string;
}
The relay stores secret_hash and mac_public_key in Durable Object storage. It stores the hash, not the secret — it can verify that a joining client has the correct secret, but cannot derive it.
QR Code
The Mac renders a QR code containing room_id and room_secret. The user scans this with the iPhone app once to pair.
iPhone side — join_room
The iPhone sends a join_room message with its own public and ephemeral keys. The relay forwards both sides’ public keys to each other via peer_joined:
export interface PeerJoinedMessage {
type: "peer_joined";
public_key: string;
session_nonce?: string;
ephemeral_key?: string;
}
Key exchange
Both devices now have each other’s ephemeral public keys. They perform X25519 ECDH and run the output through HKDF-SHA256 to produce a 32-byte AES-256-GCM session key. The key never transits the relay. See X25519 Device Pairing and HKDF Session Key Derivation for detail.
Encrypted Relay
All terminal data is encrypted before leaving either device:
// mac_agent/Sources/RemoteDevCore/SessionCrypto.swift
public func encrypt(_ data: Data) throws -> Data {
guard let key = sessionKey else { throw CryptoError.noSessionKey }
let nonce = AES.GCM.Nonce()
let sealed = try AES.GCM.seal(data, using: key, nonce: nonce)
return nonce.withUnsafeBytes { Data($0) } + sealed.ciphertext + sealed.tag
}
The relay protocol reflects this explicitly:
export interface RelayMessage {
type: "relay";
payload: string; // Opaque E2E encrypted data, server never reads
}
PTY Sessions
On the Mac, each terminal session is a real PTY process created with forkpty(). The shell (typically /bin/zsh) runs as a child of the Mac CLI daemon. Output from the shell flows:
zsh stdout/stderr
→ PTY master FD (non-blocking read via GCD DispatchSource)
→ output buffer (32KB or 200ms, whichever comes first)
→ encrypt with AES-256-GCM
→ relay_batch message → Cloudflare
→ iPhone decrypts → SwiftTerm feedData
Input flows in reverse:
iPhone keyboard input
→ encrypt → relay → Mac CLI daemon
→ decrypt → write() to PTY master FD
→ zsh stdin
The shell process has no visibility into the network layer. It sees a standard PTY.
What the Relay Can and Cannot See
Can see:
- Connection metadata: timestamps, IP addresses, connection duration
- Account data: email, subscription tier
- Room IDs (UUIDv4, no semantic content)
- Encrypted payload sizes (not content)
Cannot see:
- Terminal input or output
- Commands, file contents, environment variables
- Anything inside an encrypted
relaypayload
This is structural, not policy-based: the relay has no session key and no mechanism to obtain one.
Message Types
// relay_server/src/types.ts — full protocol
// Client → Server
type ClientMessage =
| RegisterRoomMessage // Mac: register room, announce public key
| JoinRoomMessage // iOS: join room, announce public key
| RelayMessage // either: forward encrypted payload
| RelayBatchMessage // either: forward multiple payloads (50ms coalescing)
| HeartbeatMessage; // either: keepalive (30s interval)
// Server → Client
type ServerMessage =
| RoomRegisteredMessage // confirm Mac registration, max_sessions
| PeerJoinedMessage // announce peer's public key + nonces
| PeerRelayMessage // forwarded encrypted payload
| PeerRelayBatchMessage // forwarded batch
| PeerDisconnectedMessage // peer left
| HeartbeatAckMessage // mac_connected bool (iOS queries Mac status)
| QuotaExceededMessage; // usage exceeded, resets_at timestamp