System Integration

QR Code as a Secure Pairing Mechanism: One-Time Token and TOFU

TermOnMac uses a QR code for initial pairing between iPhone and Mac. The QR code carries a room_id and a one-time pairing token. The token is single-use, the secret is rotated immediately after pairing, and the Mac’s identity key is locked in via TOFU.

What’s in the QR Code

The Mac CLI generates a QR code containing the room ID and a pairing token. The pairing token is a short-lived, one-time-use credential — separate from the long-lived room secret.

When the Mac registers, it sends a pairing_token_hash along with the standard fields:

// relay_server/src/types.ts
export interface RegisterRoomMessage {
  type: "register_room";
  room_id: string;
  secret_hash: string;
  public_key: string;
  session_nonce?: string;
  ephemeral_key?: string;
  pairing_token_hash?: string; // SHA-256 hash of one-time pairing token
}

The relay stores this hash but never sees the token itself. The QR code is the only place the token exists in plaintext.

The Single-Use Flag

Room state tracks whether the pairing token has been used:

// relay_server/src/types.ts
export interface RoomState {
  // ...
  pairing_token_hash?: string;
  pairing_token_used?: boolean;
}

When iOS submits join_room with a pairing_token_hash, the relay checks the used flag:

// relay_server/src/room.ts (handleJoin)
if (msg.pairing_token_hash && roomState.pairing_token_hash) {
  // One-time pairing token flow
  if (roomState.pairing_token_used) {
    this.send(ws, { type: "error", code: "AUTH_FAILED", message: "Pairing token already used" });
    ws.close(4003, "pairing token already used");
    return;
  }
  if (msg.pairing_token_hash === roomState.pairing_token_hash) {
    authenticated = true;
    // Mark token as used
    roomState.pairing_token_used = true;
    await this.state.storage.put("room", roomState);
  }
}

After a successful pairing, the token is marked as used. Any further attempts with the same token are rejected. If an attacker captured the QR code (e.g. by photographing it), they would be unable to use the token after the legitimate user paired.

Two Authentication Paths

handleJoin accepts either a pairing token or a room secret:

// Authenticate: accept secret_hash (reconnect) or pairing_token_hash (initial pairing)
let authenticated = false;
if (msg.pairing_token_hash && roomState.pairing_token_hash) {
  // One-time pairing token flow
  // ...
} else if (msg.secret_hash) {
  // Standard room secret flow (reconnect)
  authenticated = msg.secret_hash === roomState.secret_hash;
}
  • First connection: iOS submits pairing_token_hash derived from the QR code
  • Subsequent connections: iOS submits secret_hash derived from the persistent room secret stored in iOS Keychain

The iOS app stores the room secret only after the pairing flow is complete and the secret has been rotated.

Auth Failure Lockout

Failed join attempts are counted and tracked at both the room level and the IP level:

if (!authenticated) {
  this.joinFailures++;
  // Record IP-level auth failure
  const tags = this.state.getTags(ws);
  const clientIP = tags.length > 0
    ? (await this.state.storage.get<string>(`ip:${tags[0]}`)) || "unknown"
    : "unknown";
  await recordAuthFailure(this.env.RATE_LIMIT_KV, clientIP);

  if (this.joinFailures >= 3) {
    this.send(ws, { type: "error", code: "AUTH_FAILED", message: "Too many failed attempts" });
    ws.close(4003, "too many auth failures");
  } else {
    this.send(ws, { type: "error", code: "AUTH_FAILED", message: "Invalid credentials" });
    ws.close(4003, "auth failed");
  }
  return;
}

After 3 failed attempts, the room rejects further joins with a “too many failed attempts” error. The IP-level rate limiter tracks failures across rooms.

Account Mismatch on Join

The pairing token authorizes the connection, but the user account must also match. If iOS joins with a different account than the one that registered the Mac, the relay rejects the join:

const registeredUserId = await this.state.storage.get<string>("registered_user_id");
const joiningUserId = this.userIdBySocket.get(ws) || "";
if (registeredUserId && joiningUserId && registeredUserId !== joiningUserId) {
  // Look up both profiles to include emails in the error
  let macEmail: string | undefined;
  let iosEmail: string | undefined;
  // ...
  this.send(ws, {
    type: "error",
    code: "ACCOUNT_MISMATCH",
    message: "This Mac is registered under a different account. Please sign in with the same account on both devices.",
    mac_email: macEmail,
    ios_email: iosEmail,
  });
  ws.close(4004, "account mismatch");
  return;
}

The error response includes both the Mac account email and the iOS account email so the user can see exactly which accounts are mismatched.

Secret Rotation After Pairing

After successful pairing, the Mac generates a new room secret and sends it to iOS via an encrypted message. iOS persists the new secret to Keychain. The Mac re-registers with the new secret_hash:

// relay_server/src/room.ts (handleRegister)
// Same Mac socket re-registering (secret/token rotation): silently update the
// stored hashes without triggering room_registered / peer_joined, which would
// restart the auth handshake and break the current encrypted session.
if (existing && ws === this.macSocket) {
  if (existing.secret_hash !== msg.secret_hash) {
    updated.secret_hash = msg.secret_hash;
    changed = true;
  }
  // ...
}

After rotation, the original pairing token is no longer needed for future joins — iOS uses secret_hash of the new secret. The QR code is one-time-use both because the token is single-use and because the secret it implies has been rotated away.