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.
Links that die without closing¶
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
(
heardAgoon 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.
Link test limits¶
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)