Architecture

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 relay payload

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