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.