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.