Introduction
rex-iroh is a sidecar service for Rex that allows clients and servers on different machines to interface natively with each other via iroh.
Motivation
Rex already makes it possible for clients to talk to servers on different machines; in fact, that’s kind of the whole point! However, to do so securely you must have everything on a Tailnet, which assumes a lot about the devices you’re going to be using. You may not want to have your laptop or phone on a VPN all the time or constantly be setting up Tailscale auth.
Instead, rex-iroh connects devices over iroh and plugs into Rex at each end through Unix sockets, allowing clients and servers to interoperate without any modification. Connections are e2e encrypted, and only the devices you pair can connect.
Getting Started
rex-iroh (or any rex-iroh compatible service) must be installed on both devices
you wish to pair. You also need Rex itself: its server running on the device
you want to reach, and the rex command on each device you connect from.
Installation
Cargo
cargo install rex-iroh --locked
Both methods build from source, which takes a few minutes.
Nix
# Test before installation
nix run git+https://tangled.org/isaaccorbrey.com/rex-iroh -- --help
# Install
nix profile install git+https://tangled.org/isaaccorbrey.com/rex-iroh
Starting the server
On the device whose Rex server you want to reach, start rex-iroh serve and
leave it running:
rex-iroh serve
# forwarding to /home/you/.local/state/rex/run/server.sock
# allowlist /home/you/.local/share/rex-iroh/allowlist
# id c33be3af4267c19d409e48f837c269ff429cb0fbd8ab4ae0562d9dd584707e18
# ticket endpointadbtxy5pijt4dhkatzepqn6cnh7ufhfq7pmk...
serve finds your Rex server by asking rex whoami. If the forwarding to
line names the wrong socket, pass the right one with --rex-server, in the same
unix:// form that rex -S takes.
Nobody can connect while serve isn’t running, so once you’ve tried it out you
probably want to run it as a service.
Pairing
On the device with the Rex server running (the issuer), ensure rex-iroh serve is running, then:
rex-iroh pair
# <qr code>
# rexpair:?ticket=...&secret=...
# expires in 120s
This emits an invite which can be used to pair the issuer with a joiner (any
client device basically). The invite includes the iroh ticket for the issuer as
well as a short-lived, single-use secret. Transfer it to the joiner via copy
paste or by scanning the rendered QR code, then on the joiner (keep the single
quotes, since the invite contains &):
rex-iroh join 'rexpair:?ticket=...&secret=...' --label "Isaac's Pixel 8" \
--host desktop
# joining <...>
# check the other device shows: intact protect dutch want answer
--label is what the issuer calls the joiner, and --host is what the joiner
calls the issuer, including as a Rex host. Both are optional. Without --host,
the issuer goes by the start of its id, such as c33be3af42.
The security phrase printed is computed from the issuer’s and joiner’s identities and the secret. It’s very important to make sure this phrase matches before accepting the pair; if it doesn’t match, someone has stolen your invite and is attempting to pair instead of you, which will grant them any and all permissions your user account on the issuer has, or has tampered with your invite in some way.
Meanwhile, the terminal running rex-iroh pair on the issuer shows the
request along with its own copy of the words. Answering anything but y
declines it, and join fails with rejected: declined.
request from Isaac's Pixel 8
device 695cc6e6d2
words intact protect dutch want answer
pair with this device? [y/N] y
paired; added to allowlist
Connecting
Once the issuer accepts, join saves its ticket under the name you gave, and
adds a Rex host by that name, so rex -S desktop reaches the issuer:
accepted
saved desktop (c33be3af42)
added Rex host desktop → unix:///home/you/.local/share/rex-iroh/sockets/desktop-695cc6e6d2.sock
with `rex-iroh connect` running: rex -S desktop session ls
Rex keeps its hosts in your home, so your other devices see the host too. Its
endpoint is a socket on this device, which other devices skip. If a host by
that name already exists, join adds this device’s socket to it rather than
making another. Pass --no-host to leave Rex’s hosts alone.
Then start connect and leave it running:
rex-iroh connect
# as 695cc6e6d2...
# desktop /home/you/.local/share/rex-iroh/sockets/desktop-695cc6e6d2.sock
It opens a socket for every issuer you’ve joined, named after the issuer and
this device, in the sockets directory of the state directory. Any rex
command pointed at a socket talks to that issuer’s Rex server, whether through
the Rex host, or with -S or REX_SERVER set to the socket’s unix:// URL.
Issuers you join or forget later are picked up within a couple of seconds,
without restarting connect, so it’s worth running it as a service
too.
To reach a single issuer at a socket of your choosing instead, name it:
rex-iroh connect desktop --socket ~/.rex-desktop.sock
Pairing only works in one direction. The joiner can now reach the issuer, but the issuer can’t reach the joiner; for that, pair again with the roles swapped.
Running as a Service
Linux (systemd)
Save this as ~/.config/systemd/user/rex-iroh.service, with the path to your
rex-iroh binary in ExecStart:
[Unit]
Description=Rex over iroh
[Service]
ExecStart=%h/.cargo/bin/rex-iroh serve
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
Then enable and start it:
systemctl --user enable --now rex-iroh
systemctl --user status rex-iroh shows whether it’s running, and
journalctl --user -u rex-iroh shows its output, including its ticket.
systemctl --user disable --now rex-iroh stops it and keeps it from starting
again.
By default, systemd only runs your user services while you’re logged in. On a headless server, or any machine you want to reach without logging in to it first, turn on lingering for your user so they start at boot and keep running:
sudo loginctl enable-linger $USER
Some systems let you run loginctl enable-linger without sudo, but minimal
installs without polkit refuse it with Access denied.
macOS (launchd)
Save this as ~/Library/LaunchAgents/rex-iroh.plist, replacing the program
path with the output of which rex-iroh and you with your username:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>rex-iroh</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.cargo/bin/rex-iroh</string>
<string>serve</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/rex-iroh.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/rex-iroh.log</string>
</dict>
</plist>
Then load it:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/rex-iroh.plist
A launch agent runs while you’re logged in to the Mac, and starts again each time you log in.
launchctl print gui/$(id -u)/rex-iroh shows whether it’s running, and
~/Library/Logs/rex-iroh.log has its output, including its ticket. To stop it
and keep it from starting at login:
launchctl bootout gui/$(id -u)/rex-iroh
Pointing serve at Rex
If serve picks the wrong Rex server, or can’t find one, set REX_SERVER in
the service’s environment.
With systemd, add this under [Service]:
Environment=REX_SERVER=unix:///path/to/server.sock
With launchd, add this to the <dict>:
<key>EnvironmentVariables</key>
<dict>
<key>REX_SERVER</key>
<string>unix:///path/to/server.sock</string>
</dict>
If Rex isn’t running yet when the service starts, serve exits and the service
manager retries until it is.
Running connect as a service
To keep a socket open for every issuer you’ve joined, use the same setup under
another name, such as rex-iroh-connect, with connect in place of serve:
ExecStart=%h/.cargo/bin/rex-iroh connect
With launchd, name the plist and its Label rex-iroh-connect, and put
connect in ProgramArguments in place of serve.
One service covers every issuer. It picks up issuers you join or forget while it runs, so there’s nothing to change or restart when you do.
If an issuer revokes this device, connect keeps running and serving the other
issuers. Its log shows revoked by peer or denied by peer for that issuer;
see Troubleshooting.
A device that only joins doesn’t need the serve service at all.
Managing Peers
Each issuer keeps an allowlist of the devices that may connect to it. Pairing adds a device to it, and nothing else does unless you edit the file yourself.
Listing peers
rex-iroh peers list
# 695cc6e6d2 Isaac's Pixel 8 paired 2026-11-12 1 connection
# 0f1e2d3c4b Work laptop paired 2026-10-30 1 connection
Revoking a peer
rex-iroh peers revoke "Work laptop"
# revoked Work laptop (0f1e2d3c4b); closing 1 connection(s)
You can name the device by its exact label, its full endpoint id, or the start of
its id, as long as that start matches only one device. If serve is running,
the device’s open connections close right away, cutting off any session in
progress.
Editing the allowlist by hand
The allowlist is a plain text file, by default allowlist in the state
directory, the directory where rex-iroh keeps this device’s key and settings.
(If you start serve with --allowlist pointing somewhere else, note that
peers list and peers revoke still read the default file when serve isn’t
running.) It has one endpoint id per line, and # starts a comment. Pairing
writes each device as a comment with its label and the date, followed by its id:
# Isaac's Pixel 8, paired 2026-11-12
695cc6e6d2fedb02b93852baf30a0023e7d3ab2e315cd10a650b079889dd24a8
You can add, remove and annotate lines yourself. A running serve checks the
file every two seconds, so there’s no need to restart it. Removing a line has
the same effect as peers revoke: that device’s connections close.
If an edit leaves any line unreadable, for example an id with a typo, serve
ignores the whole edit, logs a warning naming the line, and keeps using the last
version that worked. That means a device you removed in the same edit can still
connect. serve saves each version that worked as allowlist.bak, so the same
holds if the file breaks while serve is stopped. While the file is unreadable,
peers list and peers revoke fail with an error naming the bad line, so
running peers list after an edit is a quick way to check it.
To add a device without pairing, put its endpoint id in the file. The device can
print its id with rex-iroh id. You then need to get the issuer’s ticket to
that device yourself, since pairing is what normally hands it over. serve
prints the ticket when it starts.
On the joiner
A joiner keeps the ticket of each issuer it has joined in the peers directory
of its state directory, in a file named after the issuer, as given to join --host. These are the devices it can connect to, not ones that can connect to
it, so peers list on a joiner doesn’t show them. peers joined does:
rex-iroh peers joined
# HOST ID SOCKET
# desktop c33be3af42 /home/you/.local/share/rex-iroh/sockets/desktop-695cc6e6d2.sock
Files saved before issuers had names are named after the issuer’s full id. They still work, under the issuer’s short id; joining that issuer again renames the file.
To stop reaching an issuer, forget it:
rex-iroh forget desktop
# removed desktop (c33be3af42)
# removed this device's endpoint from Rex host desktop (1 endpoint left)
# removed its socket
That also takes this device’s socket off the issuer’s Rex host, or removes the host if nothing else is left on it.
To rename an issuer, join it again with a new --host, which moves this
device’s socket to the Rex host of the new name. Deleting or renaming a file by
hand works for connect, but leaves this device’s socket on the old Rex host;
rex-iroh forget <old name> takes it off.
None of this affects the issuer, which keeps the device on its allowlist until it’s revoked there.
Troubleshooting
pair says it can’t reach serve
Error: connecting to serve's control socket "…/rex-iroh/control.sock"
Caused by:
No such file or directory (os error 2)
No serve is running with the same state directory. If you run serve as a
service, check that the service and your shell agree on REX_IROH_DIR.
serve can’t find Rex
Error: no Rex server listening at: "/path/to/server.sock"
Start Rex, or pass the right socket with --rex-server. If serve couldn’t
find a socket at all, the error lists everything it tried, in the order it
searches.
serve says it isn’t online
not online after 10s; the ticket may lack a relay address
serve couldn’t reach iroh’s relay servers, so other devices may not be able
to reach it with the ticket it printed. Check this machine’s internet
connection, and whether a firewall blocks outgoing UDP or HTTPS.
rex says there’s no server at the connect socket
Error: no Rex server is running at /home/you/.rex-desktop.sock. …
connect isn’t running, so nothing is listening on that socket. Look at the
terminal where you started connect, or its log if you run it as a service
(journalctl --user -u rex-iroh-connect). Check that rex-iroh peers joined
lists the issuer with the socket you’re using.
rex gets disconnected right away
If connect’s log shows one of these, the issuer turned this device away:
WARN rex_iroh: desktop: denied by peer: c33be3af…; each new client tries again
WARN rex_iroh: revoked by peer: c33be3af…
connect with no arguments keeps the issuer’s socket, and tries the issuer
again for each new client. connect with --socket exits instead.
denied means this device isn’t on the issuer’s allowlist. If you ran join
with a different state directory or REX_IROH_DIR than connect, run
connect with the one you joined with. Otherwise the issuer removed this
device while it wasn’t connected, and you’ll need to pair again.
revoked means the issuer revoked this device while it was connected. Pair
again to get back in. A running connect with no arguments picks the issuer
back up by itself once it lets this device in, and logs admitted again.
connect says another one is running
Error: another `rex-iroh connect` is already serving "…/rex-iroh/sockets"
Only one connect without arguments can serve a state directory. Stop the
other one, or check whether it’s already running as a service.
rex hangs, then gets disconnected
connect is running, but it can’t get anything through to the issuer’s Rex
server. connect’s log says which part failed:
WARN rex_iroh::tunnel: opening stream to peer error=dialing c33be3af… error.sources=[timed out, …]
means connect couldn’t reach the issuer at all, typically after about 30
seconds. Check that serve is running there and that the issuer is online.
connect keeps its socket open and reaches the issuer again once it’s back, so
there’s no need to restart it.
If the connection closes right away instead, the issuer may be up but unable to
reach Rex. Its serve log then shows
WARN connecting to Rex at …: Connection refused; start Rex on the issuer.
join waits the same way when the issuer is unreachable, so if it prints the
five words and then nothing happens, check serve on the issuer.
join couldn’t add the Rex host
couldn't add Rex host desktop: no Rex binary found (tried: "rex", …)
add it yourself with: rex hosts add desktop unix:///…/sockets/desktop-695cc6e6d2.sock
The pairing worked and the issuer is saved; only the Rex host is missing. Run the printed command once Rex is installed or working.
no Rex server running here, and rex-iroh doesn't start one means just that:
rex-iroh never has Rex start a server. Start Rex, or run the printed command,
which starts one as Rex usually does. If the error quotes Rex
itself, such as `rex hosts --json` said "…", that’s Rex’s own complaint.
If the host already exists, use rex hosts edit desktop --endpoint … instead,
repeating each endpoint it already has, since --endpoint replaces them all.
The invite is rejected
Whatever the reason, run rex-iroh pair again for a new invite.
rejected: bad_secret also shows up if serve restarted after printing the
invite, or if the invite expired more than an hour ago.
rejected: declined means someone answered no at the issuer’s prompt.
rejected: used, when you didn’t use the invite yourself, means someone else
had a copy and used it first. If the issuer accepted them, revoke that device
with rex-iroh peers revoke.
The socket path is too long
Error: socket path of 136 bytes is too long (max 103): "…"
Choose a shorter --socket path, such as one directly in your home directory.
The limit also applies to the state directory, since serve creates
control.sock inside it.
join checks the socket connect will use for the issuer before pairing:
Error: the socket for desktop would be 112 bytes, over the limit of 103: "…"; pick a shorter --host name, or a --state-dir with a shorter path
Pick a shorter name, or point REX_IROH_DIR at a directory with a shorter
path. The state directory has to be the same for join and connect.
Commands
Every command accepts --state-dir <DIR>, which picks the directory holding the
device’s key, allowlist and saved peers. It defaults to REX_IROH_DIR if that’s
set, and otherwise to your platform’s default location. The first
command you run creates the directory and a new key.
rex-iroh help <command> prints the same options shown here.
rex-iroh serve
rex-iroh serve [--rex-server <URL>] [--allowlist <FILE>]
Accepts connections from devices on the allowlist and forwards them to the
local Rex server. It also handles pairing, so rex-iroh pair and the live parts
of rex-iroh peers only work while it runs. It runs until it gets Ctrl-C or
SIGTERM.
--rex-server <URL>- The Rex server to forward to, as a
unix://URL. Also read fromREX_SERVER. Without it,servelooks for Rex on its own. --allowlist <FILE>- The allowlist to use instead of the one in the state directory.
On startup it prints the Rex socket and allowlist it’s using, then this device’s endpoint id and, once it’s reachable, its ticket:
forwarding to /home/you/.local/state/rex/run/server.sock
allowlist /home/you/.local/share/rex-iroh/allowlist
id c33be3af4267c19d409e48f837c269ff429cb0fbd8ab4ae0562d9dd584707e18
ticket endpointadbtxy5pijt4dhkatzepqn6cnh7ufhfq7pmk...
If no Rex server is listening on the chosen socket, serve exits with an error
instead of starting.
rex-iroh pair
rex-iroh pair [--accept]
Asks the running serve for an invite and prints it, both as a QR code and as
a rexpair: URI, then asks whether to accept the device that uses it.
--accept- Accept whoever uses the invite first, without asking. This skips the word check, so only use it when you’re confident nobody else can see the invite.
rex-iroh join
rex-iroh join <INVITE> [--label <LABEL>] [--host <NAME>] [--no-host]
Uses an invite from rex-iroh pair to pair this device with the issuer, and
saves the issuer’s ticket under a name so connect can reach it later. It also
adds the socket connect serves the issuer on to the Rex host of that name,
making the host if there isn’t one. Put quotes around the invite, since it
contains & and ?.
--label <LABEL>- The name the issuer sees for this device in the pairing prompt and in
peers list. --host <NAME>- What this device calls the issuer, which also names its socket under
connectand its Rex host. A name is up to 32 lowercase letters, digits,-and_, starting with a letter or digit. Without it, an issuer joined before keeps its name, and a new one goes by its short id. Joining an issuer again with a new name renames it, and moves a file from before names to its named file. --no-host- Leave Rex’s hosts alone.
joinprints therex -S unix://…form to use instead.
Before pairing starts, join refuses a name already used for a different
issuer or by a file in peers/ that can’t be read, and a name whose socket
path would be too long for Unix.
Joining an issuer again under a new name also takes this device’s socket off the Rex host of the old name.
If rex can’t be found or fails, the pairing still stands. join says what
went wrong and prints the rex hosts add command to run yourself. It looks for
rex the same way serve does. rex-iroh never has Rex start a
server, so on a device where no Rex server is running, join says so and
leaves the host to you.
rex-iroh forget
rex-iroh forget <ISSUER>
Forgets an issuer this device joined: deletes its ticket, takes this device’s
socket off its Rex host, and deletes the socket. If the socket was the host’s
only endpoint, the host is removed; other devices’ endpoints are left alone. A
running connect stops serving the issuer and removes the socket itself
within a couple of seconds.
ISSUER is the issuer’s exact name or its full id; the start of an id isn’t
enough. If no joined issuer has that name, forget still cleans up after it:
this device’s socket on the Rex host of that name, and the socket itself. That
finishes off a forget that was cut short, or an issuer whose file you deleted
or renamed by hand.
If rex fails, the issuer is still forgotten, and forget says how to take
the socket off the host yourself. The issuer still lists this device until it’s
revoked there.
rex-iroh connect
rex-iroh connect
rex-iroh connect <PEER> --socket <PATH>
Opens a local socket that leads to an issuer’s Rex server. Use it with
rex -S unix://PATH, or by setting REX_SERVER to that URL. Only your user can
connect to the socket.
Without arguments, connect opens sockets/<name>-<id>.sock in the state
directory for every joined issuer, where <id> is this device’s short id, and
prints each name and socket. It checks peers/ every two seconds, so issuers
you join or forget while it runs gain or lose their socket without a restart.
Forgetting an issuer, or joining it again, ends any session still open through
its socket. Only one connect without arguments can run per state directory.
On startup it removes sockets an earlier one left behind for issuers you’ve
since forgotten.
If an issuer turns this device away, connect logs it once and goes on
serving the other issuers. The issuer’s socket stays open, and each new client
on it tries the issuer again, closing as soon as it’s refused. Once the issuer
lets this device back in, its socket works again and connect logs that too.
With PEER and --socket, it serves just that one peer:
<PEER>- A joined issuer’s name, its id, or the start of its id if that matches only
one issuer; or any ticket, as printed by
serve. A bare endpoint id of an issuer you haven’t joined also works, but then iroh has to look up the issuer’s address, which can be slower or fail where a ticket wouldn’t. --socket <PATH>- Where to create the local socket. The path has to be short enough for a Unix
socket: 107 bytes on Linux, 103 on macOS. If a socket is left over at that path
from a process that has exited, it’s replaced; if something is still
listening on it,
connectrefuses to start.
In this form, if the issuer turns this device away, connect exits with status
1.
Either way, connect only dials an issuer when a client uses its socket. If
the connection drops for any reason but the issuer turning this device away, it
redials the next time something uses the socket.
rex-iroh peers list
rex-iroh peers list
Lists the devices on the allowlist, with their labels and pairing dates. If
serve is running, it also shows each device’s open connections.
rex-iroh peers joined
rex-iroh peers joined
Lists the issuers this device has joined, which are the ones it can reach, with
the socket connect serves each on:
HOST ID SOCKET
desktop c33be3af42 /home/you/.local/share/rex-iroh/sockets/desktop-695cc6e6d2.sock
rex-iroh peers revoke
rex-iroh peers revoke <PEER>
Removes a device from the allowlist. PEER is the device’s exact label, its
full endpoint id, or the start of its id if that matches only one device. If
serve is running, the device’s open connections close right away.
rex-iroh id
rex-iroh id
Prints this device’s endpoint id: 64 hex characters that identify the device to others. It’s derived from the device’s key, so it stays the same until that key is deleted.
Files and Environment
The state directory
rex-iroh keeps everything it remembers in one directory per device:
| Platform | Default location |
|---|---|
| Linux | $XDG_DATA_HOME/rex-iroh, usually ~/.local/share/rex-iroh |
| macOS | ~/Library/Application Support/rex-iroh |
Set REX_IROH_DIR or pass --state-dir to use a different one. Two state
directories on one machine act as two separate devices, each with its own key.
| File | What it holds |
|---|---|
secret.key | This device’s private key. Its public half is the device’s endpoint id. |
allowlist | The devices allowed to connect to this one. |
allowlist.bak | The last allowlist that parsed, which serve falls back on. |
peers/ | The tickets of issuers this device has joined, one file each, named after it. |
sockets/ | Each joined issuer’s socket for connect, named after it and this device. |
control.sock | The socket pair and peers use to talk to a running serve. |
The key is created the first time any command runs. Anyone with a copy of
secret.key can act as this device, so rex-iroh creates it readable only by
you, and refuses to start if any other user has any access to it.
Deleting it gives the device a new identity, and every issuer it was paired
with will turn it away until it pairs again.
Environment variables
REX_IROH_DIR- The state directory, as with
--state-dir. REX_SERVER- The Rex server
serveforwards to, as with--rex-server. Rex sets this in its own terminals and reads it too, so inside a Rex terminalserveuses the server that terminal belongs to. RUST_LOG- How much to log, as a
tracingfilter. The default,rex_iroh=info, logs each connection that’s accepted or refused and the outcome of each pairing.rex_iroh=debugadds why each connection ended.
How serve finds Rex
Without --rex-server or REX_SERVER, serve looks for a Rex server in this
order and uses the first one it finds:
- The socket reported by
rex whoami, using therexon yourPATH. If thatrexturns out to be some other program with the same name, it’s skipped. If it is Rex but can’t name a socket,servegoes straight to step 3. - The socket reported by
rex whoami, using arexbinary in one of the places Rex’s installers put it:- Linux:
/usr/bin/rex,/usr/local/bin/rex,~/.local/bin/rex - macOS: inside the Rex app’s support directory,
~/Library/Application Support/com.superlogical.rex/RexServer/Versions/Current/Rex Server.app/Contents/MacOS/rex - Either:
~/.nix-profile/bin/rex,/run/current-system/sw/bin/rex
- Linux:
- Rex’s default socket, if a server is listening on it:
- Linux:
$XDG_STATE_HOME/rex/run/server.sock, usually~/.local/state/rex/run/server.sock - macOS:
~/Library/Application Support/rex/server.sock
- Linux:
Looking never starts a Rex server. Whichever socket it picks, serve checks
that a server is listening there before going any further.
Security Model
What a paired device can do
A paired device can do anything with your Rex server that you can. Rex sees
every forwarded connection as coming from the user running serve, which is
you. So a paired device can open terminals and run commands as your user on the
issuer.
Treat pairing like adding an SSH key to authorized_keys. Pair devices you
control, and revoke them when you stop using them or lose them.
Pairing only goes one way: the joiner gets access to the issuer, and the issuer
gets nothing on the joiner. The devices that can connect to you are exactly the
ones rex-iroh peers list shows.
Identity
Each device has a key pair, kept in secret.key in its state directory. The
public key is the device’s endpoint id, and the endpoint id is the only thing
rex-iroh treats as identity. When a device connects, iroh proves which key it
holds during the connection handshake, and the issuer checks that key against
its allowlist.
Everything else a device says about itself is display text. Labels are chosen by the joiner and can say anything, which is why the pairing prompt shows the start of the endpoint id and the five words next to the label.
Pairing
An invite contains the issuer’s ticket, which says how to reach it, and a random 32-byte secret. The issuer accepts the secret once, within 120 seconds of creating it. After that, the invite is useless.
Within those 120 seconds, though, anyone who has the invite can use it. If
someone copies the invite and uses it before you do, the issuer gets their
request instead of yours, and your own join is rejected because the invite
was already used. The five words catch this. Each side computes them
from the invite’s secret and both devices’ endpoint ids, so an attacker’s
request produces different words from yours.
rex-iroh pair --accept skips the prompt and with it the word check: whoever
uses the invite first is paired. Use it only when you’re sure nobody else can
see the invite before you use it.
The protocol spells out how the words are computed and why they hold up.
The connection
rex-iroh connects devices with iroh, which encrypts each connection end to end with QUIC and authenticates both ends by their keys. Devices find each other through n0’s public relay servers and address lookup service. When they can’t reach each other directly, traffic passes through a relay. Relays carry the encrypted connection but can’t read it.
Revocation
When serve is running, peers revoke takes effect immediately: the device’s
open connections close, and new ones are refused. Removing its line from the
allowlist by hand does the same within about two seconds, the next time serve
checks the file, but only if the rest of the file is valid. If any line is
unreadable, serve keeps the previous allowlist, device included. Prefer
peers revoke, and run peers list after editing the file by hand to confirm
the device is gone.
Local files and sockets
rex-iroh creates its key, allowlist, control socket and the sockets connect
opens so that only your user can use them. Someone who can already act as your
user on a machine can use them too, but they could use your Rex server directly
anyway.
rex-iroh protocol
How devices pair with each other and then reach each other’s Rex control
socket over iroh. Two ALPNs make up the protocol:
rex-pair/0 adds a device to another’s allowlist, and rex/0 carries Rex
control connections between them.
The issuer is the device being paired with: it creates the invite, decides on requests, and owns the allowlist. The joiner is the device holding the invite and asking to be let in.
The key words MUST, MUST NOT, SHOULD and MAY are to be read as in RFC 2119.
The files under spec/fixtures/ are part of this document; an
implementation is conformant when it handles each of them as described in
Fixtures.
Identity
Every device has a long-lived ed25519 key. Its public key is the device’s iroh endpoint id, and that id is the device’s identity. On the wire the id is the raw 32-byte public key; written as text it is 64 lowercase hex digits.
Identity comes only from the QUIC handshake: the endpoint id a connection was authenticated with. Nothing a peer says about itself (a label, a ticket, any field in any message) is identity.
Invite
An invite is one URI:
rexpair:?ticket=<ticket>&secret=<secret>
QR codes, pasted text and deep links are only ways of carrying that string.
<ticket>is the issuer’s iroh endpoint ticket in iroh’s own string form: the lowercase prefixendpointfollowed by the ticket’s bytes in lowercase base32. It tells the joiner which endpoint id to dial and how to reach it.<secret>is 32 bytes from a cryptographically secure random source, encoded as base32 with the RFC 4648 alphabet in lowercase (abcdefghijklmnopqrstuvwxyz234567) and no padding. That is exactly 52 characters, and the low 4 bits of the last character are zero.
The issuer keeps everything else about the invite (when it expires, that it may be used once, what to do when it is used) to itself. None of it is in the URI.
Encoders MUST produce exactly rexpair:?ticket=<ticket>&secret=<secret>.
Decoders:
- MUST match the scheme
rexpaircase-insensitively, and MUST require it to be followed immediately by:?. There is no authority and no path. - MUST ignore a
#and everything after it. - MUST split the query on
&into parameters, and each parameter at its first=into a name and a value. - MUST NOT percent-decode
ticketorsecret: neither encoding produces characters that need escaping, so a%in either value makes it invalid. - MUST reject the invite if
ticketorsecretis missing or appears more than once, if the secret is not exactly the encoding above (wrong length, uppercase, padding, non-zero trailing bits, any other character), or if the ticket does not decode. - MUST ignore every other parameter, including empty ones and ones without an
=.
The decoder does not trim whitespace; a transport that may add some, such as pasted text, trims it before decoding.
Pairing: ALPN rex-pair/0
Exchange
- The joiner connects to the endpoint id in the invite’s ticket with ALPN
rex-pair/0, opens one bidirectional stream, writes one request, and finishes its send side. - The issuer reads the request and decides (see Issuer).
- The issuer writes one response on the same stream and finishes its send side.
- The joiner reads the response to the end of the stream, then closes the connection with code 0. The issuer waits up to 10 seconds for that close before closing the connection with code 0 itself.
Both messages are a single JSON object encoded as UTF-8, with nothing but JSON whitespace before or after it. Any other JSON value, such as an array, is malformed, as is an object with a repeated member name.
Request
At most 4096 bytes.
{"secret": "<secret>", "label": "<text>", "ticket": "<ticket>"}
| Field | Required | Type | Meaning |
|---|---|---|---|
secret | yes | string | The invite’s secret, in the invite’s encoding. |
label | no | string | A name for the joiner to show its user. Untrusted display text. |
ticket | no | string | The joiner’s own endpoint ticket, so the issuer can dial it back. Not identity; the issuer MAY ignore it. |
The issuer MUST ignore fields it does not know. An optional field set to null
is treated as absent.
Response
At most 4096 bytes. One of:
{"result":"accepted"}
{"result":"rejected"}
{"result":"rejected","reason":"<reason>"}
reason | Meaning |
|---|---|
bad_secret | The secret is not one the issuer created (or not decodable). |
expired | The invite’s lifetime ended before the request arrived. |
used | The invite was already redeemed. |
declined | The issuer’s user said no. |
timeout | The issuer asked its user and got no answer in time. |
The issuer MAY omit reason. The joiner MUST treat a reason it does not know
as a rejection without a reason, and MUST ignore fields it does not know. A
result other than accepted or rejected is malformed.
Issuer
On a request, the issuer:
- checks the secret in constant time against the invites it has issued: one
that it issued, that has not expired, and that has not been used. If the
check fails it responds
rejectedwithbad_secret,expiredorused; an invite that is both used and expired is reported asused. - marks the invite used as soon as the secret checks out, before asking
anyone anything, so a second request with the same secret gets
usedeven while the first is still waiting on its user. - applies its own policy for that invite (see Policy).
- on acceptance, adds the joiner’s endpoint id from the handshake to its
allowlist, then responds
accepted. Otherwise it respondsrejected.
The issuer closes the connection without a response when:
| Condition | Close code | Name |
|---|---|---|
The request is not valid JSON, not an object, lacks secret, has a known field of the wrong type, or exceeds 4096 bytes | 1 | protocol_error |
| The joiner opens any stream after the first, of either kind | 1 | protocol_error |
| No complete request arrives within 10 seconds of the connection | 2 | request_timeout |
A secret that is a string but not a valid encoding is not malformed: it gets
bad_secret.
If the joiner’s connection ends while the issuer is waiting on its user, the issuer abandons the wait and adds nothing to its allowlist.
Policy
What the issuer does with a valid secret is its own business, not part of the
protocol: it might accept at once, or ask its user. A device that asks its
user MUST answer timeout when no answer arrives within a bound of its
choosing.
Joiner
The joiner SHOULD wait for the response for as long as the connection stays open, since the issuer may be waiting on a person, but SHOULD give up after a bounded wait. A connection that closes before a complete response arrives is a failed pairing.
After accepted, the joiner can reach the issuer over rex/0 using the ticket
from the invite.
Short authentication string (SAS)
Both devices compute the SAS on their own. Nothing about it goes on the wire.
h = SHA-256("rex-pair/0 sas" || secret || issuer_id || joiner_id)
"rex-pair/0 sas"is those 14 ASCII bytes, with no terminator or length.secretis the invite’s raw 32 bytes, not its base32 text.issuer_idandjoiner_idare the raw 32-byte public keys: the issuer’s from the invite’s ticket on the joiner, and the joiner’s from the handshake on the issuer.
The first 55 bits of h, most significant bit of h[0] first, are five
11-bit numbers, each also most significant bit first. Each number indexes the
BIP39 English word list (2048 words, index 0 is
abandon, index 2047 is zoo; SHA-256 of the file is
2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda). The SAS
is those five words in order.
The issuer SHOULD show the SAS when it asks its user about a request, next to the label and a fingerprint of the joiner’s endpoint id. The joiner SHOULD show the SAS while it waits for the response. An issuer that accepts without asking skips the comparison by design.
Why it holds up: a label is whatever the requester typed, so an impostor holding a leaked invite can copy it, but the SAS depends on the impostor’s own key, so its words won’t match unless it finds a key that collides on 55 bits. Because the secret is mixed in, that search can’t start before the invite exists and has to finish before it expires.
Tunnel: ALPN rex/0
The issuer admits a rex/0 connection only from endpoint ids on its allowlist.
Each bidirectional stream the joiner opens is one connection to the issuer’s Rex control socket. The stream carries that connection’s bytes unchanged in both directions. A half-close passes through: finishing a stream’s send side shuts down the writing half of the socket connection on the other end, and the end of the socket’s output finishes the stream. If the issuer can’t reach its Rex socket, it resets the stream.
The issuer closes a rex/0 connection with:
| Code | Name | When |
|---|---|---|
| 0 | Normal close. | |
| 1 | denied | The peer isn’t on the allowlist. |
| 2 | revoked | The peer was removed from the allowlist while connected. |
A peer MUST treat any other code as a normal close.
Fixtures
Every fixture is exact bytes; none ends in a newline unless the description says so.
spec/fixtures/invite/
ticket.txtis the issuer’s ticket used in the invites (its key is the ed25519 key from the seed of 320x07bytes) andsecret.hexis the invite secret in hex (00 01 … 1f).- Every URI in
valid/decodes to that ticket and secret.valid/canonical.uriis exactly what an encoder produces for them. - Every URI in
invalid/must be rejected. The file name says why.
spec/fixtures/request/
All requests use the invite secret above. valid-ticket.txt is the ticket in
valid.json, which belongs to a different key (seed of 32 0x08 bytes) from
the one any test dials with, so an issuer that took identity from it would
allowlist the wrong id.
| File | The issuer must |
|---|---|
valid.json, missing-label.json, unknown-field.json, max-size.json (exactly 4096 bytes) | accept the request |
unknown-secret.json, undecodable-secret.json | respond rejected with bad_secret |
oversize.json (4097 bytes), malformed-*.json | close with code 1 and no response |
spec/fixtures/response/
| File | The joiner must read it as |
|---|---|
accepted.json, accepted-unknown-field.json | accepted |
rejected.json, rejected-unknown-reason.json | rejected, no reason |
rejected-<reason>.json | rejected with that reason |
malformed-*.json | a failed pairing |
accepted.json, rejected.json and each rejected-<reason>.json for a known
reason are also the exact bytes this implementation’s issuer sends.
spec/fixtures/sas.json
SAS test vectors: for each, the secret and both public keys in hex (with the ed25519 seeds the keys come from), the full hash, the five indices and the five words.