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

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.