Cryptography

Trust On First Use: How TermOnMac Verifies Your Mac's Identity on Reconnect

When a Mac reconnects to an existing room, TermOnMac verifies that it’s the same Mac that originally registered — not an impostor claiming the same room ID. This is Trust On First Use (TOFU): the first identity key that registers a room is trusted, and subsequent registrations must match.

First Registration

On first register_room, the relay stores the Mac’s identity public key in room state:

// relay_server/src/room.ts
const roomState: RoomState = {
  room_id: msg.room_id,
  secret_hash: msg.secret_hash,
  mac_public_key: msg.public_key,  // stored for TOFU on reconnect
  created_at: Date.now(),
  mac_session_nonce: msg.session_nonce,
  mac_ephemeral_key: msg.ephemeral_key,
  // iOS fields preserved if room already existed...
};
await this.state.storage.put("room", roomState);

Reconnection Check

On subsequent register_room messages for the same room, the relay compares the submitted public_key against mac_public_key stored in room state:

// relay_server/src/room.ts (handleRegister)
const existing = await this.state.storage.get<RoomState>("room");

if (existing && existing.secret_hash !== msg.secret_hash) {
  this.send(ws, { type: "error", code: "AUTH_FAILED", message: "Room secret mismatch" });
  return;
}

If secret_hash doesn’t match, registration is rejected immediately. The mac_public_key comparison is implicit: if the registering device doesn’t have the correct roomSecret, it can’t produce the correct secret_hash.

Account Ownership Check

In addition to TOFU on the device key, the relay also checks that the reconnecting user account matches the original room owner:

const registeredUserId = await this.state.storage.get<string>("registered_user_id");
const registeringUserId = this.userIdBySocket.get(ws) || "";
if (registeredUserId && registeringUserId && registeredUserId !== registeringUserId) {
  this.send(ws, {
    type: "error",
    code: "ACCOUNT_MISMATCH",
    message: "This room is owned by a different account.",
  });
  ws.close(4004, "account mismatch on register");
  return;
}

A room cannot be claimed by a different account, even if they have the correct roomSecret.

Secret Rotation Without Disruption

When a new iOS device pairs via QR code, the room secret is rotated (the Mac generates a new secret, sends it to iOS, and re-registers with the new secret_hash). This happens without dropping the current session.

The relay handles a re-registration from the same socket (same Mac, already in the room) as a silent update:

// 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) {
  let changed = false;
  const updated: RoomState = { ...existing };
  if (existing.secret_hash !== msg.secret_hash) {
    updated.secret_hash = msg.secret_hash;
    changed = true;
  }
  if (msg.pairing_token_hash && existing.pairing_token_hash !== msg.pairing_token_hash) {
    updated.pairing_token_hash = msg.pairing_token_hash;
    updated.pairing_token_used = false;
    changed = true;
  }
  if (changed) {
    await this.state.storage.put("room", updated);
  }
  return;
}

room_registered and peer_joined are not re-sent. The active encrypted session continues uninterrupted with the new secret hash in place for future reconnections.

Old Socket Cleanup on Reconnect

When the Mac reconnects with a new WebSocket (e.g. after a network drop), the relay closes the old socket and cleans up its storage entries:

if (this.macSocket && this.macSocket !== ws) {
  // Eagerly clean up old socket's storage entries so stale role mappings
  // don't confuse hibernation recovery.
  const oldTags = this.state.getTags(this.macSocket);
  if (oldTags.length > 0) {
    await this.state.storage.delete(`role:${oldTags[0]}`);
    await this.state.storage.delete(`user:${oldTags[0]}`);
    // ...
  }
  try { this.macSocket.close(4001, "replaced by new connection"); } catch {}
}

The 4001 close code signals that the old connection was intentionally replaced, not dropped due to an error.