Skip to content

Connecting to a machine

Approving a computer: the 6-digit check

When you approve a computer, two keys are swapped: your browser key goes to the computer, and the computer's ID goes to your browser. Our website carries both. So how do you know we (or anyone in between) didn't swap them for fakes?

Both screens show a 6-digit pairing code made from both keys. If anything was swapped, the codes come out different.

  your browser                    your computer
  +---------------------+         +-------------------------------+
  | Pairing code        |         | Pairing code:  482 190        |
  |    482 190          |  same?  |                               |
  +---------------------+         | Does your browser show        |
                                  | exactly this pairing code?    |
                                  | [y/N] y                       |
                                  +-------------------------------+

Only after you type y on the computer itself does it start trusting your browser. Your browser can't see what you type there, so until the computer connects it says Not finished yet; once it has, the page notices, says the computer is approved and takes you to Open sessions.

Reinstalled a computer? A fresh install is a new identity, so it would show up as a second computer. If you already have one of your own with the same name, the approval page offers Replace: the new install takes over the old entry (same place in the list, same sharing), and the old install stops working. You still compare the pairing codes as usual. Other browsers that had the old identity saved will warn that the computer's identity changed, which is correct.

Setting up a server from the website? There the page sees both codes: the server's arrives straight through the SSH connection you checked, which our website can't change, and the browser works out its own from what our website passed on. So the page compares them for you, shows it, and answers y or n itself.

Why nobody can fake a matching code

Six digits is only a million possibilities, and a computer can try a million fake keys in seconds. So each side also throws in a secret random number, and the computer keeps its own sealed in an envelope until your browser has committed to its key:

  your computer                 our website              your browser
  roll a secret number
  send its SEALED ENVELOPE ------------------------->  (can't see inside)
                                                        roll a secret number,
                       <-------------------------  send it with the browser key
  only now open the envelope ----------------------->  check it's the number
                                                        that was sealed
  code = both keys + both numbers    ==   code = both keys + both numbers

Anyone faking a key has to pick it before they can see the computer's number, so they get one blind guess in a million. If the envelope's contents don't match what was sealed, your browser shows no code and tells you to type n.

Connecting: the locked conversation

 you click Connect
      |
      v
 browser --(1) "connect me to home-pc"--> website: is this YOUR
                                          computer?
                                          yes -> tells home-pc:
                                          "a browser wants you"
 home-pc opens a second line out to the website
 website joins the two lines together
      |
      v
 browser <========== locked conversation ==========> home-pc
   checks: "is this really         checks: "is this a browser
   home-pc?" (its ID must          I approved?" (your key
   match the one you approved)     must be on its list)

For a computer shared with you, we check the share instead. If you belong to an organisation, its rules (such as which second step it requires) are checked too.

Everything you type and everything that comes back is locked by your browser and your computer, not by us. We pass along the locked pieces.

Setting up a server over SSH

When you set up a server from our website, your browser logs in to it with SSH itself. Our server is only the middleman: it connects to your server's SSH door and passes bytes back and forth, but the lock is agreed between your browser and your server alone.

 YOUR BROWSER ==== locked (only these two have the key) ==== SERVER
                        |
               our server: passes the
               bytes along, can't open
               them

What we see: which server you set up, when, and roughly how much data passed. Not your password or key, and not anything typed or shown during setup. Nothing is kept.

The one weak spot is the first contact. A dishonest middleman can't open the lock, but it could pretend to be your server. That's why the page shows your server's fingerprint (its ID) before sending anything, and goes on only when you say it matches. With an SSH key instead of a password, even a successful impostor gets nothing it could reuse.

Our server only does this for signed-in accounts, only to servers on the public internet (never home or office networks, or our own server), and only a limited number of times.

If something's wrong

  • Your computer's ID doesn't match: your browser stops and warns you. It never quietly trusts a new ID.
  • Your browser has no saved ID for a computer: it refuses to connect, even if our server says what the ID should be. Only the ID you checked with the 6-digit code counts.
  • Your browser isn't on the computer's list: the computer refuses ("Login failed").
  • Sharing: someone you share a computer with only gets in after you and they have compared a code, and it's your browser, not our server, that adds them to the computer's list. By default they get a full terminal; you can limit them (watch only, a time limit, what else they may do), and the computer keeps those limits itself.
  • Stop sharing, or removing a computer: a guest's open terminal is cut at once, and a computer you remove ends its open terminals. If the computer was offline, the guest's key comes off it the next time it's online while you have roamd open.
  • A guest who may only watch: the computer, not the page, keeps them to it. They get no shell and can't run commands; typing is dropped on the computer, and a time limit is kept there too. They can still see everything on the screen, so don't show them secrets.
  • Your organisation: it owns seats and rules (for example a required second step, or who you may share with), which can hold back reaching computers or sharing until they are met. It never has your computers, terminals, vault or settings.
  • Someone steals the computer's connection token: they still can't pretend to be your computer, because they don't have its ID key.
  • A server's fingerprint doesn't match when you set it up: choose It doesn't match, and nothing at all is sent to it.
  • The connection drops (a phone sleeps, the network changes): a tmux session is rejoined by itself, the same session: at once, then a second later, then a second after that. You can see it happen: a pill over the terminal says the connection was lost, an amber line slides along the tab's bottom edge. It gives up after three tries and offers Reconnect (a round-arrow icon beside the tab). A computer that is restarting (after an update, say) takes longer than that, so a tab that gave up tries again by itself the moment our server says the computer is back, with nothing to press. A plain shell ended with the connection and can't be rejoined, and a session that no longer exists is never quietly replaced by a new one. Coming back to the tab acts at once: each open terminal is asked a tiny question that roamd answers itself (it starts no program, so a Windows computer, where starting PowerShell can take seconds, answers as quickly as any), and one that doesn't answer in time is treated as dropped and rejoined straight away. "In time" is about a second, longer for a computer that is always slow to answer (far away, or behind a VPN): the page learns how quick each computer usually is, from its handshake and from each answer, and gives it a few times that, so a slow but working link isn't cut off and rebuilt.
 page shown --> tiny question to each terminal
                  |
                  +-- answer within its limit --> fine; the answer updates "usual"
                  +-- no answer ---------------> dropped, rejoin at once

 limit = a few times this computer's usual answer, never under a second, never over a few

When the browser says it is offline, open terminals are treated as dropped at once and no tries are spent: the tab says Offline, and the session is rejoined the moment the browser is online again. A terminal that had given up tries again too, so a sleeping phone wakes the moment you return, not when a timer next fires. Each try at rejoining has a short time to answer. A network that died while the phone slept can leave a try waiting for ever, so one that gets no answer is dropped and the next try starts, instead of the terminal sitting on "Disconnected". - A connection dies while you're looking at it (the Wi-Fi changes, a router forgets it) without closing: your computer sends a small "still here" now and then, and answers your typing straight away. If a terminal hears nothing for much longer than that, or nothing since you typed a moment ago, the page asks it the same tiny question; if there's no answer it rejoins, with nothing to press. A terminal that is simply quiet answers and is left alone.

  ```
   terminal quiet for a long time, or typing got no reply
                    |
             tiny question --> answer -----> fine, nothing changes
                           --> no answer --> dropped, rejoin at once
  ```
- **A computer loses its internet without saying goodbye**: our server checks that every
  connection still answers. One that stops answering is closed after a short while, so the
  computer shows **offline** instead of looking online while connecting to it fails. The same
  goes for a phone or browser that vanished: its terminals stop counting as open (on the free
  plan they no longer use up your two).
- **A laptop wakes from sleep**: roamd on it notices at once that its connection died while
  it slept, and connects again, instead of waiting as if no time had passed.
- **Our server restarts unexpectedly**: each computer sends its list of sessions again as soon
  as it reconnects, so **Open sessions** fills in straight away.

When the app is hidden, unfinished connection checks are cancelled. Returning starts fresh
checks, with repeated wake-up events sharing the same work. A brief recovery can finish
without flashing the terminal haze; typing remains blocked until a lost connection is ready.
A recovery that continues shows the haze, and a failed recovery still shows its warning.

```
hide -> cancel old check
return -> fresh check -> reconnect if needed -> ready
                                            -> still recovering -> haze
```

## Keeping your page up to date

While roamd is open and unlocked, the page keeps one quiet line open to our server. Our server
uses it to say when something you can see has changed: a computer came online or went
offline, a session is waiting for you, a share changed, or you changed something in another
browser. The page then fetches just that. When nothing changes, nothing is sent except a
small "still here" now and then.

```
 your computer --- something changed ---> our server --- "this changed" ---> your page
                                                                              |
                                                   fetches just that, as usual
```

- **Nothing new is shared.** The line only says *what kind* of thing changed. The page then
  fetches it exactly as before, with the same checks. Session status is sent as the sealed
  report your computer made, and only to browsers that were already allowed to fetch it.
- **If the line drops** (a phone sleeps, the network changes), the page reconnects by
  itself and fetches everything once, so nothing is missed. It also fetches everything now
  and then anyway. If the line died while you were on another tab, the page checks it the
  moment you come back, and opens a new one straight away if it has gone silent.
- **If the line can't be opened at all**, the page simply goes back to asking every few
  seconds, as it used to.
- **Signed out somewhere else?** If this browser is signed out from another one, the page
  locks straight away and says so.

## If coming back to a tab feels slow

Nothing is recorded by default. If you want to see where the time goes, you can switch on a
timing log in your own browser:

1. Open the browser's developer console on the roamd page.
2. Type `localStorage.setItem('roamd-trace', '1')` and reload the page.
3. Switch to another tab, wait, and switch back.
4. In the console, type `copy(__roamdTrace.join('\n'))`, then paste the result to whoever is
   helping you.

The log lists, in order, when the page was hidden or shown, when a terminal's link closed,
when it was tested or rejoined, how long the page was hidden, when the browser went offline
and came back, whether anything you typed was lost because there was no link, how long the
whole restore took from the drop, and how long each step took. Getting back in is split into
its parts: reaching roamd, the locked handshake with your computer, and asking it for its
sessions, so a slow one stands out:

```
 rejoin try starts
   |
   +-- reach roamd ......... socket   N ms
   +-- locked handshake .... handshake N ms
   +-- ask for sessions .... list     N ms
   |   (all three: "session list took N ms")
   +-- join the session .... "rejoin took N ms"
   v
 back after the drop
```

It also prints each line in
the console as it happens. It stays in your browser; nothing
is sent anywhere. To turn it off, type `localStorage.removeItem('roamd-trace')` and reload.

Tabs after a reload. The saved tabs are all opened at the same moment, each made first in its saved order and then connecting on its own. Before, they went one after another, so the third waited for the first two to finish setting up.

Enrolment (device-code flow + pairing code)

 roamd login               hub                        browser (logged in)
 -----------               ---                        -------------------
 create host key H (Ed25519)
 random secret Nm
 register H + C = SHA-256(fp(H), Nm)
                               ------>
 print approve link                                   open link, see
                                                      name, fp(H), C
                                                      random secret Nb
                                                      approve: send B.pub, Nb
 collect B.pub, Nb, token <------
 reveal Nm                     ------>                check
                                                      SHA-256(fp(H), Nm) = C
                                                      (else: no code,
                                                       "type n")
                                                      pin fp(H) in vault
 code = 6 digits from SHA-256 over fp(H), fp(B), Nm, Nb   (both sides)
 user compares with browser, types y
 only then: trust B.pub, save settings (sealed)

If the hub substituted H or B, the two sides would compute different pairing codes. The user rejects, and nothing is written. In web SSH setup the browser holds both codes, the machine's from the pinned SSH session, so it compares them and answers on the user's behalf (see Installation). Approval codes are short-lived, single-use and rate-limited.

Replacing. The approval page is told which of the account's own devices share the new machine's name, and approving may name one to replace. The hub accepts that only for a device of the same account with the same name. It keeps the device id, shares and settings, stores the new host key and token hash (the old token stops working) and cuts the old tunnel. The y typed on the machine is never sent anywhere; the page learns of approval when the machine comes online.

Why the secrets. A 6-digit code alone has only 20 bits: a hub that saw both real keys could grind substitute keys until one gave the same code. Here each side contributes a 256-bit secret in a commit-then-reveal order (as in Bluetooth numeric comparison): the machine commits to Nm before learning anything about the browser, the browser sends Nb only after seeing that commitment, and the machine reveals Nm only after B and Nb are fixed. Whatever a hub in the middle substitutes, it must choose before it can predict the code, so its chance of a match is one in a million per attempt, and each attempt needs the user to approve and confirm.

Tunnel and session setup

 roamd run --(outbound TLS WebSocket, device token)--> hub  (kept open)
 browser --(TLS WebSocket, session cookie, origin check)--> hub
    hub: does this account own the machine (or hold an active share)?
    do its organisation's rules allow it? is the machine online?
    hub asks the machine to open a second stream
 hub splices the two streams (bytes only)
 browser SSH client <==== SSH, end to end ====> roamd SSH server
  • Browser side: the machine's host key must match the pinned identity in the vault, saved when the pairing code was confirmed. There is no trust-on-first-use: with no pin the browser refuses, and the fingerprint the hub records is never used in its place. Authentication uses the vault's Ed25519 key.
  • Machine side: only keys the user confirmed on this machine are accepted, plus guest keys an owner's browser added, with their limits kept on the key.
  • Algorithms: post-quantum hybrid key exchange (ML-KEM-768 + X25519), falling back to Curve25519; ChaCha20-Poly1305 or AES-GCM; strict key exchange.

Event line (hub to page)

 page (unlocked) --(TLS WebSocket, session cookie, origin check)--> hub
    hub: signed in? our own page? under the account's limit of open lines?
    hub registers the line: which account, which machines it may see
    hub --> "ready"; page then fetches everything once (nothing missed in between)

 a request changes something ----> hub tells the pages concerned "changed: <kinds>"
 a machine connects or drops ----> "changed: devices" to the pages that see it
 a machine posts its status -----> that status, filtered as the status list filters it
 every so often -----------------> "still here"; line re-checks its sign-in and what it sees
 sign-in ended ------------------> line closed with "signed out"; page locks
  • Hints, not data. A change is one of a few kinds (machines, shares, mutes, vault). The page fetches it through the same endpoints and checks as before, so telling a page that did not need it gives that page nothing. A sealed status is the one thing carried whole, chosen by the same rule as the status list: all envelopes for the owner, only the guest's own envelope for a guest let in, nothing for anyone else.
  • One place tells. Changes are announced by a layer wrapped around the hub's routes, after a request succeeds, not by each handler, so a new handler can't forget. Who is concerned is looked up both before and after the change (a removed share can't be looked up after). Machines connecting and dropping, and guests' terminals opening and closing, are announced where the hub sees them, with no lock held.
  • Nothing queues. Per line the hub holds only which kinds are still to be said and the newest status per machine, so a slow page costs no more than a quick one. Lines live in memory only; a restart ends them and each page reconnects and fetches everything.
  • Sign-ins. Every way the hub knows a sign-in ends (signing out, signing a browser out, a new password, recovery, an organisation turning a member off) has the lines concerned check at once; each line also checks now and then, which catches a sign-in simply running out, and a share running out by time.
  • Limits. Each account may hold only a limited number of open lines, and open them only so often. Messages from the page are refused beyond a small size; buffers are small. The operator can switch the line off, and every page then asks every few seconds again.
  • Page side. While the line is up, the page's timers fetch nothing; they only look again at what has gone stale, and retry work waiting on machines (switching to a new browser key, removing a departed guest's key). The page reconnects with growing, slightly random waits, at once when it is shown again or comes back online, and treats a line that has gone quiet for too long as dropped. On being shown again it makes that check at once, rather than waiting for its own timer (which browsers slow while a page is hidden), and replaces the line without waiting for the old one to finish closing. While it is down, it asks every few seconds as before.

Server setup relay

For web-app server setup the hub is a TCP relay: the browser's WebAssembly SSH client talks to the server's SSH port through a WebSocket the hub splices to a TCP connection it opened. Key exchange, authentication and the session are end to end between browser and server; the hub handles ciphertext only.

 browser                  hub                        server
 -------    ---------------------------    ----------------
 check  -->  account + own origin?
             address public, not hub?
             SSH port or high port?
             hourly allowance left?
             connect, greets "SSH-"?  ---->  TCP + greeting
 probe  -->  relay                    ---->  key exchange only
 (refuses every host key: learns the fingerprint, no auth)
 user compares the fingerprint; stops here on "no"
 login  -->  relay (same checks)      ---->  pinned host key,
                                              password or key
  • Addresses are checked after name resolution, and the vetted address is the one connected to. Refused: private, loopback, link-local, shared and reserved ranges, IPv6 forms that wrap IPv4 addresses, and the hub's own addresses. The hub's service also blocks private ranges at the operating-system level.
  • Each connection is capped in duration; setups are limited per account per hour.
  • Trust on first contact rests on the user's fingerprint check (the browser has no pin for a server it has never seen). Public-key authentication binds the signature to the session, so an impersonator gains no reusable credential; a password would be exposed to one. The page says so.

What the hub can do if it's compromised

Attack Outcome
Read or change the shell session No: SSH is end to end, and changes break its integrity check
Pose as the machine with a stolen device token Browser refuses: no host private key, pin mismatch
Hide or roll back a pin so the browser trusts a new key No: no pin means no connection; deletions from the hub are ignored and older revisions refused (see the vault)
Add its own key to the machine No: only keys confirmed on the machine via matching pairing codes, which it can't force to match, or guests' keys an owner's browser adds over its own SSH session after owner and guest compared a sharing code
Pass off its own key (or a fake machine) when sharing No: the sharing code covers the guest's key and the machine's identity; a swap shows the two people different codes
Give a guest more than the owner chose (type when watch only, stay after the end time) No: the machine keeps the limits on the guest's key and checks them itself; the hub's copy only decides routing and display. A limit the machine can't read gives no access
Keep access after "Stop sharing" by swapping its own key No: the machine keeps the share's name on the guest's key through a swap, and ending the share removes every key that carries it. If the computer was offline at the time, the key comes off the next time it's online while you have roamd open; roamd keys revoke does it at once
Read a server setup session, or the password or key used No, once the user has checked the server's fingerprint: SSH is end to end
Pose as a server being set up Only if the user accepts a fingerprint that doesn't match; with key authentication it still gets nothing reusable
Deny or drop service Yes (it's the relay)
Serve malicious web-app code Possible: see limits

Tunnel hardening (agent)

The agent only talks to the hub over HTTPS (an address with a user@ part is refused, as it can disguise the real host). It validates and size-limits everything the hub sends, strips control characters from hub text before printing it, limits how many streams can be open at once, drops a hub that goes silent (measured by the wall clock as well as by its timers, which stand still while the machine sleeps, so a machine that wakes reconnects at once), and reconnects with randomised back-off. Each time the hub takes its connection it sends its sealed session report again at once, so a hub that lost the reports it held (a crash, not a normal restart, which keeps them) shows them again straight away. If the hub rejects its token, roamd run stops. The hub likewise caps message sizes and how many connections may wait for one machine. While a connection is open the hub keeps checking that the machine still exists and, for a guest, that the share and both organisations' rules still allow it; if not, the connection ends.

A link whose far end vanished (no close, no reset: a lost network, a sleeping phone, Cloudflare in between keeping its own side open) can look open for many minutes, until the operating system gives up on it. Both ends now notice sooner.

  • The hub pings each machine's control link, its spare stream, both sides of every terminal and every page's event line, and notes when each last sent anything (an answer to a ping counts; browsers and roamd answer by themselves). A link silent for longer than its quiet limit, a few pings' worth, is closed: a machine is then shown offline (its last-seen time is recorded, as for any drop), a terminal's other side is closed too, and its place under the free plan's cap is freed. A machine's link replaced by a newer one is recorded as ended at that moment.
  • The page keeps, for each terminal, when anything last arrived from the machine (heardAgo on the WebAssembly terminal) and when typing was sent with no reply since. While the page is in view it looks every few seconds; a terminal silent for well over the machine's SSH keep-alive interval, or unanswered a few seconds after typing, gets the link test (below). An answer clears it; no answer drops the link and rejoins at once.
 hub, every ping:   side silent > quiet limit ? close it (both sides of a terminal)
 page, every look:  silent >> keep-alive  or  typed, no reply  ?  link test
                                               answer --> keep
                                               none ----> drop, rejoin

Rejoining a machine that was away

A tab that used up its tries is kept aside (in the page) with the machine's hub connection marker as the device list showed it when the link dropped. Each time the list changes (the hub's event line pushes a fresh one when a machine connects), a parked tab whose machine is listed online with a different marker starts its tries afresh, once per new marker. A restart (an update, a service restart) takes longer than the tries, so without this the tab waited for the page to be shown again or for Reconnect. A tab whose machine is still on the same marker (the trouble was the network, not the machine) is left alone, so a link that can't work isn't retried in a loop.

 link drops --> three quick tries --> all fail --> parked, marker A
 device list: machine online, marker B != A --> tries afresh
 device list: still marker A ---------------> stays parked

Timing log

lib/trace.ts is a log that is off unless roamd-trace is set to 1 in the browser's local storage. When on, it keeps the most recent lines in memory (window.__roamdTrace) and echoes them to the console. It records the page events (visibility, page show and hide, online and offline, focus, freeze and resume), how late the page's timers run (browsers slow them in a hidden tab), how long the page was hidden when it is shown again, and, for each terminal, when its link closed, when it was tested and how long the answer took, and each rejoin try with its outcome and duration, the total time from the drop to being back (or to giving up), the waits for the network while offline, and typing dropped because there was no link. The first part of a connection (listing the tmux sessions on a connection that is then kept) is timed as a whole by the page, and in its parts by the WebAssembly side, which hands back the WebSocket open, the SSH handshake (key exchange and login) and the list command, in ms. A listing that fails or is given up on still logs its total. Each link test logs its limit and the computer's usual answer time beside the result. It never leaves the browser.

The test asks the machine's roamd for its version over the update subsystem, which roamd answers itself: running a command would start the machine's shell, and PowerShell alone can take seconds to start on Windows, so a working link looked dead. A refusal (a guest, or a roamd without updates) still proves the link works; only a broken connection counts as no answer.

probeLinks in lib/terminal-session.ts gives each machine its own limit for the link test, learned only for this visit and kept in memory. The first guess at one round trip is a third of the SSH handshake from its first listing; each answer then moves it part of the way (a running average), so it follows the network. The limit is a few times that, clamped between a floor (the old fixed limit) and a ceiling. A probe that times out teaches nothing.

Page suspension cancels pending probes and their deadlines. Late results from cancelled checks cannot learn latency or drop a connection. Concurrent callers share each live link's pending probe; resume events share an in-flight batch. Returning from visibility suspension or the page cache opens a bounded presentation grace period for reconnecting terminals only. Socket closure still immediately invalidates the link and blocks input, and rejoining starts immediately. Presentation grace does not defer connection repair or permit queued typing. Duplicate foreground events cannot prolong it. If recovery is still running afterwards, the normal lost-connection presentation appears; failed recovery remains visible immediately.

suspend -> cancel deadline -> ignore late result
resume -> one fresh probe per live link
       -> reconnect -> input blocked + brief visual grace
                    -> still reconnecting -> lost presentation
 listing --> handshake / 3 --> usual[machine]   (first time only)
 probe answered in t -------> usual = blend(usual, t)
 limit = clamp(floor, few x usual, ceiling)