Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 from REX_SERVER. Without it, serve looks 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 connect and 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. join prints the rex -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, connect refuses 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:

PlatformDefault 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.

FileWhat it holds
secret.keyThis device’s private key. Its public half is the device’s endpoint id.
allowlistThe devices allowed to connect to this one.
allowlist.bakThe 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.sockThe 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 serve forwards to, as with --rex-server. Rex sets this in its own terminals and reads it too, so inside a Rex terminal serve uses the server that terminal belongs to.
RUST_LOG
How much to log, as a tracing filter. The default, rex_iroh=info, logs each connection that’s accepted or refused and the outcome of each pairing. rex_iroh=debug adds 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:

  1. The socket reported by rex whoami, using the rex on your PATH. If that rex turns out to be some other program with the same name, it’s skipped. If it is Rex but can’t name a socket, serve goes straight to step 3.
  2. The socket reported by rex whoami, using a rex binary 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
  3. 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

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 prefix endpoint followed 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 rexpair case-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 ticket or secret: neither encoding produces characters that need escaping, so a % in either value makes it invalid.
  • MUST reject the invite if ticket or secret is 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

  1. 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.
  2. The issuer reads the request and decides (see Issuer).
  3. The issuer writes one response on the same stream and finishes its send side.
  4. 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>"}
FieldRequiredTypeMeaning
secretyesstringThe invite’s secret, in the invite’s encoding.
labelnostringA name for the joiner to show its user. Untrusted display text.
ticketnostringThe 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>"}
reasonMeaning
bad_secretThe secret is not one the issuer created (or not decodable).
expiredThe invite’s lifetime ended before the request arrived.
usedThe invite was already redeemed.
declinedThe issuer’s user said no.
timeoutThe 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:

  1. 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 rejected with bad_secret, expired or used; an invite that is both used and expired is reported as used.
  2. marks the invite used as soon as the secret checks out, before asking anyone anything, so a second request with the same secret gets used even while the first is still waiting on its user.
  3. applies its own policy for that invite (see Policy).
  4. on acceptance, adds the joiner’s endpoint id from the handshake to its allowlist, then responds accepted. Otherwise it responds rejected.

The issuer closes the connection without a response when:

ConditionClose codeName
The request is not valid JSON, not an object, lacks secret, has a known field of the wrong type, or exceeds 4096 bytes1protocol_error
The joiner opens any stream after the first, of either kind1protocol_error
No complete request arrives within 10 seconds of the connection2request_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.
  • secret is the invite’s raw 32 bytes, not its base32 text.
  • issuer_id and joiner_id are 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:

CodeNameWhen
0Normal close.
1deniedThe peer isn’t on the allowlist.
2revokedThe 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.txt is the issuer’s ticket used in the invites (its key is the ed25519 key from the seed of 32 0x07 bytes) and secret.hex is the invite secret in hex (00 01 … 1f).
  • Every URI in valid/ decodes to that ticket and secret. valid/canonical.uri is 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.

FileThe issuer must
valid.json, missing-label.json, unknown-field.json, max-size.json (exactly 4096 bytes)accept the request
unknown-secret.json, undecodable-secret.jsonrespond rejected with bad_secret
oversize.json (4097 bytes), malformed-*.jsonclose with code 1 and no response

spec/fixtures/response/

FileThe joiner must read it as
accepted.json, accepted-unknown-field.jsonaccepted
rejected.json, rejected-unknown-reason.jsonrejected, no reason
rejected-<reason>.jsonrejected with that reason
malformed-*.jsona 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.