Skip to content

Draft. This text can change at any time, and there's no version history until the draft is frozen. Comments stay attached to the passage they were made on; if it changes, they move to its section, marked as on an earlier version.

Tieroute protocol specification

Status

Version 1.0, draft. The wire version is the major number, 1 (§19.1). This draft v1 covers private chat (direct chats and groups of up to 25 people) and voice calls between contacts. Community servers come in a later version.

The v1 core

These parts are what v1 conformance means:

  • §3 to §11: cryptography, encoding, discovery, identity, devices, transport, contacts, delivery, and groups
  • from §12: the app-message envelope, its checks, and text content, with the formatting of §12.1 (clients that don't render formatting show body as plain text, which is still readable)
  • the exact APIs: the API conventions, the client API, and the server-to-server API
  • the test vectors

The API references and the test vectors are written for wire version 1. The API references cover every endpoint in Appendix A, and rerouted, the reference server, implements all of them.

This document isn't yet enough to build a conforming implementation on its own. The conformance suite (§19.3) has only its first part so far: an attack suite that tests the server-side claims of §17.2. Security claims and their evidence lists each claim and what backs it.

Any change to the core MUST update the test vectors (§20). Once v1 is frozen, every change to the core MUST also be logged in the changelog (§22). v1 is the first wire version meant to stay stable, but it isn't frozen until the outside review in §17.4 is done (§19.1).

Unmarked text is the v1 draft. Proposals and open questions are labeled where they appear, with "Proposed" and "Open question". A proposal isn't signed off yet: MUST and SHOULD in a proposed passage show what the proposal would require, not that it's settled.

The CDDL snippets in this document are collected in spec/tieroute.cddl. If the two disagree, that's a bug.

License

Anyone may implement Tieroute, and these terms say so (LICENSE.md):

  • The specification (this document, docs/, spec/api/): CC BY 4.0.
  • Schemas and test vectors (spec/tieroute.cddl, spec/vectors/): CC0 1.0, so implementations can copy them without conditions.
  • The reference implementations (rerouted, Reroute, the website): Apache-2.0 OR MIT, at the user's option. Apache-2.0 carries an explicit patent grant; MIT keeps projects under the GNU General Public License (GPL) version 2 able to use the code.
  • A patent pledge (PATENTS.md): Reroute Holdings LLC won't assert patents against anyone implementing the spec.

Abstract

Tieroute is a federated, end-to-end encrypted (E2EE) protocol for private chat between contacts: direct chats, group chats of up to 25 people, and voice calls. This document defines its identities, discovery, the APIs between devices and servers and between servers, message delivery, and how groups use MLS (Messaging Layer Security, RFC 9420). It's written so that someone who hasn't seen Tieroute before can build a compatible server or client from it.


1. Conventions and terminology

1.1 Conventions

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" are to be read as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.

Data structures are written in CDDL (Concise Data Definition Language, RFC 8610), applied to JSON (§4). || means byte concatenation. JCS(x) means the JSON Canonicalization Scheme (JCS, RFC 8785) serialization of x, as UTF-8 bytes.

Everything in this document is normative unless it's marked informative. Informative content is: §2, the sections and notes labeled "informative," passages that start with "For example," and short rationale notes that start with "Why" or "Rationale." If an example disagrees with the normative text, the normative text wins.

When a requirement doesn't name its own consequence, a violation is handled this way:

  • A server that receives a request violating a MUST rejects it with 400 bad-request, or with the more specific error code from the API conventions that fits. Where this document says a rejection is silent (§10.3), the server answers 202 Accepted and drops it instead.
  • A client that receives a message, chain, or commit violating a MUST drops it, and doesn't apply it to its state.
  • A signature or hash that doesn't verify makes the whole object invalid, as if it had never arrived.

Numbers in this document are exact. Sizes use binary units (1 MiB = 1,048,576 bytes), times are in seconds unless a unit is given, and all timestamps are Unix seconds in UTC.

1.2 Terminology

Term Meaning
Homeserver The server a user's identity lives on. Stores the user's identity chain, key packages, contact list, and inbox.
Community server A self-hosted place with voice and text channels. Not part of v1; community servers come in a later version.
Server name A server's DNS name, for example chat.example. Identifies the server.
Identity A user. Identified by an identifier, not by a handle or server.
Identifier Permanent internal ID of an identity, for example tr:7f3ka7qmx2vbn4rtd6wycs5e. Never shown to users (§6.2).
Identity chain Append-only, signed log of changes to an identity (§6.3)
Handle A domain name you go by, for example @kat.chat.example. A changeable pointer to an identifier.
Device A single client install. Has its own device key. Each device is its own MLS member.
Recovery code Offline secret that outranks every device and can rotate everything (§6.1)
Contact An identity you've accepted, or who accepted you. Only contacts can reach you (§9).
Invite code A code that lets someone send you a contact request (§9.1). Every contact request needs one.
Hub The server that orders MLS commits for one group (§11.6). Often the group creator's homeserver.
Display name What people see you as. Free text, part of your public profile (§13.1). Separate from the handle.
Delivery A Tieroute envelope carrying one MLS message or protocol object (§10.1)
Verification code An XXXX-XXXX code two people compare to confirm there's no one in the middle (§6.7)
Tie line A federation link between two servers
Server document The JSON document every server publishes at /.well-known/tieroute/server: its name, keys, and API locations (§5.1)
Client API, S2S API The API a device uses with its own homeserver, and the server-to-server API homeservers use with each other (§8.1)
Direct chat A conversation between exactly two identities, as a two-person MLS group (§11.3)
Group chat A conversation between 2 to 25 identities, as an MLS group (§11.3)
Epoch One version of an MLS group's membership and keys. Each commit starts a new epoch (RFC 9420).
Commit, Welcome, key package MLS messages: a change to a group, an invitation that lets a new device join, and a device's published joining material (RFC 9420, §11.4)
Epoch key A signing key every member of an epoch can derive, which authorizes commit requests at the hub (§11.6)
Device-signed, recovery-signed A chain entry signed by a current device key, or by the current recovery key (§6.4)
Pending outgoing A contact request you've sent and the other person hasn't answered (§9.3)
Account key, account data A random key every identity has, and the encrypted settings stored under it on the homeserver (§7.4)
Backup passphrase A passphrase, separate from the recovery code, that unlocks the encrypted backup (§7.5)
Tombstone The chain an old homeserver keeps for an identity that moved away (§6.6)
Wire version The protocol's MAJOR version number, used in signatures and API listings (§19.1)

2. Overview (informative)

Tieroute is a federated, end-to-end encrypted protocol for private chat, built around a contact list: one-to-one chats, small group chats, and voice calls between contacts. Reroute is the reference desktop client.

2.1 Goals

  • End-to-end encryption for all private messages and calls.
  • Federation: anyone can run a server and join the network.
  • A federated identity is required for everything. No anonymous access.
  • Tieroute is an open protocol. Other people can build their own servers and clients from this spec alone. rerouted and Reroute are the reference implementations, not the only ones.
  • Avoid ActivityPub's flaws: server-owned identity, readable direct messages, a vague schema, unsigned content, fan-out stampedes, and spam pushed onto small admins.
  • Identity is portable and survives the loss of a server.
  • The wire format is plain text (JSON, §4), so traffic and stored data stay readable and can be reconstructed long after the fact.

2.2 Non-goals

  • No large public channels. Group chats have at most 25 people (§11.10).
  • No centrally hosted communities. Community servers, planned for a later version, are each self-hosted, and the contact list stays the center of the app.
  • No new cryptography. Tieroute composes MLS (RFC 9420), SFrame (Secure Frame, RFC 9605), HPKE (Hybrid Public Key Encryption, RFC 9180), ML-KEM (Module-Lattice-Based Key-Encapsulation Mechanism, Federal Information Processing Standard (FIPS) 203) in hybrid with X25519, and Ed25519 (RFC 8032).

2.3 Designed for implementers

Tieroute aims to be a protocol a hobbyist can implement. MLS itself isn't hobbyist-friendly, so the protocol keeps it contained:

  1. Servers never touch MLS. A homeserver stores and routes opaque blobs. Everything a server needs for routing and ordering (group ID, epoch, message type) is in a Tieroute envelope next to the MLS bytes (§10.1). A server needs JSON, HTTPS, Ed25519 verification, and the rules in this document. Nothing else.
  2. Clients bring their own MLS library. Tieroute doesn't ship an official client library. A client uses any existing RFC 9420 implementation that supports the Tieroute MLS profile (§11.1). Hand-rolling MLS is a bad idea, but nobody is tied to one library either. That's why the profile has to stay small: it uses only features that mainstream MLS libraries already implement.
  3. Everything is pinned. One mandatory ciphersuite, no optional extensions, and a minimal set of proposal types (§3, §11.1). Each knob removed is one less thing an implementation can get wrong.
  4. Conformance is layered. "Tieroute server" and "Tieroute client" are separate conformance badges (§19.3). Someone can ship a working server without touching the crypto side.
  5. E2EE can't be skipped. The protocol has no plaintext message type. Servers MUST reject chat deliveries that aren't MLS (§10.1). Because there's no official library to make encryption the default path, the profile and the conformance vectors have to do that job. If E2EE is hard to get working, implementers route around it, as many bots and hobby clients on other federated protocols have.

2.4 How the pieces fit together

  Kat's devices            Kat's homeserver              Ravi's homeserver           Ravi's devices
 +-------------+  client  +-----------------+    S2S    +-----------------+  client  +-------------+
 | laptop      |--------->| a.example.com   |---------->| b.example.org   |<---------| phone       |
 | phone       |<---------| identity chain, |<----------| identity chain, |--------->| desktop     |
 +-------------+   API    | inbox, contacts |    API    | inbox, contacts |   API    +-------------+
                          +--------+--------+           +-----------------+
                                   |
                                   +-- also the hub for groups Kat created: orders their commits

The diagram shows two people on two homeservers. Each of Kat's devices talks only to Kat's homeserver, over the client API (§8.1). Kat's homeserver talks to Ravi's homeserver over the server-to-server (S2S) API (§8.3), and Ravi's devices talk only to Ravi's homeserver. Kat's homeserver is also the hub for the groups Kat created (§11.6). A message travels from Kat's device to her homeserver, across to Ravi's homeserver, and into his inbox, where each of his devices picks it up. The servers read only the delivery envelope around each message (§10.1). The message itself is MLS ciphertext that only the devices can decrypt.

3. Cryptographic primitives

Tieroute pins exactly one choice for each primitive. Implementations MUST NOT negotiate alternatives in v1.

Purpose Primitive
Signatures (identity, device, server keys) Ed25519, pure (RFC 8032). Post-quantum signatures are planned, see §3.1
Hash SHA-256
MLS ciphersuite Hybrid post-quantum: MLS_128_MLKEM768X25519_AES128GCM_SHA256_Ed25519 from draft-ietf-mls-pq-ciphersuites. Revision -07 (November 1, 2026) requests code point 0x0010 from IANA (the Internet Assigned Numbers Authority). It isn't registered yet. KEM (key encapsulation mechanism): MLKEM768-X25519 (HPKE KEM 0x647A). AEAD (authenticated encryption): AES-128-GCM (the Advanced Encryption Standard in Galois/Counter Mode, with 128-bit keys). Hash: SHA-256. Signatures: Ed25519. Until 0x0010 is registered and supported by OpenMLS, Tieroute uses OpenMLS's provisional code point 0x004F, so it interoperates with the one library that implements the suite today. Then Tieroute switches to the registered value, and existing groups are re-created (§11.11). Implementations MUST NOT mix code points: a group uses exactly one.
Public-key encryption outside MLS Hybrid post-quantum: HPKE base mode with KEM MLKEM768-X25519 (0x647A, also known as X-Wing) from draft-ietf-hpke-pq, KDF (key derivation function) HKDF-SHA256 (0x0001), and AEAD AES-128-GCM (0x0001). Public keys are 1,216 bytes and encapsulations 1,120 bytes.
Call media encryption SFrame (RFC 9605), cipher suite AES_128_GCM_SHA256_128 (0x0004), keyed from MLS (§15.2)
Symmetric encryption of files and backups AES-128-GCM
Key derivation HKDF-SHA256 (RFC 5869), a key derivation function built on HMAC, the keyed-hash message authentication code
Passphrase hashing Argon2id (RFC 9106). m = 64 MiB, t = 3, p = 1
Transport TLS 1.3. Servers SHOULD offer the hybrid X25519MLKEM768 key exchange. A server MAY skip it only when its TLS library lacks it; the transport is then exposed to harvest-now-decrypt-later, though message content stays protected by E2EE.

The MLS suite uses the same KEM as the HPKE suite, plus the same AEAD and hash as everything else, so one crypto library covers all of Tieroute. Note: Both suites are still Internet-Drafts, so their identifiers can change with each new draft revision. Tieroute will switch from 0x004F to the IANA value (expected 0x0010) once it's registered and OpenMLS supports it. Nothing about the protocol changes when that happens.

3.1 Post-quantum protection

Everything that keeps messages secret uses hybrid post-quantum key exchange. The threat that applies to messages sent today is "harvest now, decrypt later": someone records ciphertext now (servers store it, networks can be tapped) and decrypts it once a large quantum computer exists. Hybrid ML-KEM-768 + X25519 stays secure as long as either half holds. Covered:

  • direct chats and group chats: the MLS ciphersuite (§11.1)
  • calls between contacts: SFrame keyed from the chat's MLS group (§15.2)
  • device link bundles: HPKE (§7.2)
  • transport: TLS hybrid key exchange

Symmetric primitives (AES, SHA-256, HKDF, Argon2id) are weakened by quantum computers but not broken, so backups, account key wrapping, and identifiers are already fine.

Signatures stay Ed25519 in v1, with pre-rotation for the recovery key. A quantum computer could forge Ed25519 signatures once it exists, but it can't forge them retroactively for messages that are already sealed. The one key that has to survive is the recovery key, your way back in. So its public key is never published until the moment it's used (pre-rotation, §6.3). An attacker can't derive a private key from a public key they've never seen.

Later: hybrid Ed25519 + ML-DSA (Module-Lattice-Based Digital Signature Algorithm, FIPS 204) signatures for chains, devices, and servers, in a future wire version, before quantum computers are a practical threat. Proposed: §3.2 would move this into v1 instead. ML-DSA-65 signatures are about 3.3 KB (ML-DSA-44's are about 2.4 KB), which is fine for chain entries but heavy for every chat message. That's one reason it waits. Until then, a quantum attacker could forge device-signed chain entries (undone by the recovery key within 72 hours, §6.4.1), forge server keys, or freeze groups by forging epoch-key signatures (§11.6). Past messages stay confidential: they're sealed with hybrid key exchange, and forging a signature later doesn't open them. Future messages don't: a forged add-device puts the attacker's device on someone's identity, and the sync rule (§11.9) then adds it to every chat, so it reads what's sent from then on. Until signatures go hybrid, the defenses are the ones that catch any unwanted device. The identity's devices get an alert (§6.4.1), every chat shows a system line (§11.9), and the owner can remove the device or undo the change with the recovery code within 72 hours. That's why hybrid signatures need to arrive well before quantum computers do.

Costs. ML-KEM-768 public keys are about 1.2 KB, and ciphertexts about 1.1 KB. Key packages grow by about that much. Groups are capped at 25 people (§11.10), which keeps MLS costs small.

Library support. This is the real cost of post-quantum from the start. As of this draft:

  • OpenMLS 0.9.2 implements the pinned suite behind its draft-ietf-mls-pq-ciphersuites feature flag (code point 0x004F). (It also ships an older experimental X-Wing suite, 0x004D, from an expired individual draft. Tieroute doesn't use it.)
  • mls-rs and smaller MLS libraries: no confirmed support for the pinned suite. Until the suites are standardized, "bring any MLS library" (§2.3) effectively means a library that supports this suite.
  • HPKE with MLKEM768-X25519 has implementations in several languages already.

3.2 Hybrid signatures in v1 (proposed)

Proposed. This section would bring hybrid post-quantum signatures into v1, instead of waiting for a later wire version (§3.1). Whether to adopt it is an open question. Until it's signed off, §3, §3.1, and §4.3 stand as written, and nothing here changes the core or the test vectors. If it's adopted, it changes the core, so the vectors, the reference implementations, and the identifier of every identity would change, before v1 is frozen.

Why now. Under §3.1, signatures have to go hybrid before a large quantum computer exists, and that takes a new wire version that every client and server adopts. Nobody can say when that deadline is. Doing it before v1 is frozen costs bandwidth and storage. Doing it later costs a coordinated migration of every identity, server, and group on the network, against an unknown deadline.

3.2.1 The signature

Proposed: every signature listed in §3.2.2 is a composite signature: ML-DSA-65 (FIPS 204) and Ed25519 (RFC 8032), combined as in draft-ietf-lamps-pq-composite-sigs, algorithm MLDSA65-Ed25519-SHA512. A verifier MUST check both component signatures, and MUST reject the object if either fails. The composite holds as long as either algorithm holds, which is the same reasoning as the hybrid key exchange in §3.1.

  • Tieroute doesn't design its own combiner. The draft's construction prepends a fixed prefix and the algorithm's identifier to the message, and hashes it, before both components sign. That stops a signature from one algorithm being reused alone, or with another combination.
  • The Ed25519 component follows every rule in §4.3: the strict scalar, the small-order list, the canonical encodings, and the cofactorless equation.
  • A composite public key is the ML-DSA-65 public key followed by the Ed25519 public key: 1,952 + 32 = 1,984 bytes. A composite signature is the ML-DSA-65 signature followed by the Ed25519 signature: 3,309 + 64 = 3,373 bytes. Each is one base64url string.
  • Note: The draft revision, its algorithm identifier, and its prefix aren't pinned yet. Once pinned, they have to be re-checked against each new revision, as §3 does for the MLS and HPKE drafts.

Why ML-DSA-65 and not ML-DSA-44. ML-DSA-65 is security category 3 of the US National Institute of Standards and Technology (NIST), which matches ML-KEM-768. ML-DSA-44 (category 2) would save about 900 bytes per signature (2,420 bytes) and 640 bytes per public key (1,312 bytes). Either is far beyond today's attacks; the recommendation is to match the key exchange.

Why ML-DSA and not SLH-DSA. SLH-DSA (Stateless Hash-Based Digital Signature Algorithm, FIPS 205) rests only on hash functions, a more conservative assumption, but its signatures are 7.8 KB at the smallest, and signing is slow. Tieroute signs every message.

3.2.2 What becomes hybrid

Proposed: composite signatures, and composite public keys, would replace Ed25519 in:

Key Where it signs Notes
Device keys Chain entries, app-messages (§12), profiles, link proofs, add approvals, key package bindings (below) The add-device op carries the device's composite public key. The device-key in MLS credentials (§7.1) stays the 32-byte Ed25519 component, so MLS is unchanged.
Recovery keys Recovery-signed chain entries (§6.3, §6.4.1) Both components come from recovery_seed_i: the Ed25519 key as today, and ML-DSA-65 from HKDF-Expand(prk, "signing-mldsa" || uint32_be(i), 32) as its 32-byte key generation seed. Pre-rotation commits to the hash of the composite public key.
Server keys S2S requests (§8.3), hub receipts (§11.6), the server document's keys (§5.1) keys in the server document carry composite public keys.
Epoch keys Commit requests (§11.6) Both components come from epoch_seed_N: Ed25519 as today, and ML-DSA-65 from HKDF-Expand(epoch_seed_N, "tieroute hub mldsa", 32). next and reg carry composite public keys.
One-time reg keys Epoch-key registration (§11.6) Composite, generated fresh.

Key package bindings. A key package is signed inside MLS with the device's Ed25519 key only. Someone able to forge that signature could publish a key package with their own encryption key under your device's name, and receive the Welcome meant for your device. So each uploaded key package travels with a kp-binding, signed with the device's composite key over the key package's hash. A client that claims key packages MUST check the binding against the device's composite key in its chain, and MUST NOT add a device whose key package has no valid binding.

kp-binding = {          ; body of a `signed`, t = "kp-binding", signed with the device's composite key
  "id":  identifier,
  "kp":  hash,          ; SHA-256 of the KeyPackage's raw bytes
}

The signed envelope (§4.3) keeps its shape, and s holds a composite signature. What k holds depends on the signer:

  • Device and recovery keys, whose composite public keys are in the signer's chain: k stays the 32-byte Ed25519 component, and the verifier looks up the composite key in the chain. A verifier that doesn't have the chain fetches it first (§6.5), as it already does to check a device. This keeps about 2.6 KB of key out of every message.
  • Epoch keys and one-time reg keys, which aren't in any chain: k holds the composite public key, checked against what the hub holds.
  • Server keys: kid names a composite key in the server document, as today. The signature input stays JCS(["tieroute/v1", t, b]), signed by both components through the composite construction. Implementations MUST reject an Ed25519-only signature wherever a composite one is required. There's no fallback and no negotiation, as with every other primitive (§3).

3.2.3 What stays Ed25519

Proposed: signatures inside MLS stay Ed25519, because they're set by the MLS ciphersuite (§11.1) and changing them needs a ciphersuite that mainstream MLS libraries support (§2.3). The binding to Tieroute keys covers the cases that matter:

  • Messages. Every app-message carries its own composite signature (§12), checked against the sender's device key in their chain. A forged MLS signature can't put words in someone's mouth.
  • Joining. Key package bindings stop forged key packages.
  • Commits and leaf updates. Only members can produce them: every handshake message is a PrivateMessage, encrypted under the group's epoch secrets. An outsider with a quantum computer can't make one. A member could forge another member's MLS signature on a commit, which is the same as a member committing under their own name: it changes who's in the group, and every member sees the change.

Note: MLS will move to a ciphersuite with post-quantum signatures once one is registered and supported by OpenMLS and at least one other library. Existing groups will then be re-created (§11.11), as with the switch to 0x0010 (§3).

3.2.4 Costs

Proposed: composite signatures add about 3.3 KB to everything signed, and composite keys about 1.9 KB to everything that carries a key.

Object Today Hybrid Change
A short text message, as delivered (base64url twice) about 0.6 KB about 6.6 KB about 11 times
A chain entry adding a device about 0.3 KB about 7.3 KB about 24 times
A chain with 5 devices and a handle about 2 KB about 40 KB about 20 times
A server document with two keys about 0.6 KB about 6 KB about 10 times
100 key packages, with bindings about 160 KB about 610 KB about 4 times
Verifying one signature about 50 µs about 150 µs about 3 times
  • Messages. The size limit of 1 MiB per delivery (conventions) still leaves room for every content type, including a 256 KiB group picture (§12).
  • Inboxes. A homeserver keeping 30 days of text for an active user stores about ten times as much. Files dominate storage anyway, and they don't grow.
  • Typing indicators and read receipts are app-messages, so each one costs a full composite signature. Open question: whether ephemeral content (typing) may carry only the Ed25519 component. It's never stored and has no lasting value to a forger, but it adds a second signature rule to the spec.
  • CPU. ML-DSA signs and verifies in well under a millisecond on current hardware, so speed isn't the constraint; size is.

3.2.5 Library support

Proposed. Library support as of this draft:

  • ML-DSA-65: implemented in Go (Cloudflare CIRCL), Rust (RustCrypto ml-dsa), and JavaScript (@noble/post-quantum), among others.
  • The composite construction: a short combination of SHA-512 and the two primitives, described exactly in the draft. Note: It isn't known yet which libraries implement MLDSA65-Ed25519-SHA512 directly. Where none do, each implementation builds it from the draft, and the test vectors pin it.
  • MLS: unchanged (§3.2.3).

4. Encoding

Every protocol object is JSON. Plain text can be inspected, logged, and preserved with ordinary tools: someone holding old Tieroute data can read it and work out its meaning without this document. CBOR (Concise Binary Object Representation) was used through v0 and replaced for v1.

4.1 JSON

  • All protocol objects are I-JSON (the Internet JSON profile, RFC 7493), described in CDDL (RFC 8610): UTF-8, no duplicate member names, and integers within ±(2⁵³ − 1). Duplicate member names are rejected everywhere, since two parsers could read different values for the same name.
  • Member names are short lowercase strings, so objects stay readable. Floating-point numbers are not used anywhere; every number is an integer.
  • Byte strings (keys, hashes, signatures, MLS messages) are base64url without padding (RFC 4648 §5) inside JSON strings. Decoders MUST reject padding, characters outside the alphabet, and non-canonical encodings (nonzero unused bits in the last character).
  • HTTP bodies use the media type application/tieroute+json.
  • Decoders are lenient except where it matters. Anywhere outside a signature or hash, decoders MUST accept any valid I-JSON: members in any order, any whitespace. Byte-exact rules apply only to the bytes that are signed or hashed (§4.3), and those are defined so nobody has to re-encode anything they received.

4.2 Common types

b64         = tstr .regexp "[A-Za-z0-9_-]*"      ; base64url, no padding (RFC 4648 §5)
b64-12      = tstr .regexp "[A-Za-z0-9_-]{16}"   ; 12 bytes
b64-16      = tstr .regexp "[A-Za-z0-9_-]{22}"   ; 16 bytes
b64-32      = tstr .regexp "[A-Za-z0-9_-]{43}"   ; 32 bytes
b64-64      = tstr .regexp "[A-Za-z0-9_-]{86}"   ; 64 bytes

identifier  = tstr .regexp "tr:[a-z2-7]{24}"     ; base32 (RFC 4648, lowercase, no padding) of 15 bytes
device-key  = b64-32                             ; Ed25519 public key; also the device's identifier
server-name = tstr                               ; lowercase DNS name, A-labels for IDN
handle      = tstr                               ; a DNS name, see §5.4
timestamp   = uint                               ; Unix time, seconds
nonce       = b64-16                             ; random
msg-id      = b64-16                             ; random
group-id    = b64-32                             ; random, chosen by the group creator
hash        = b64-32                             ; SHA-256

4.3 Signed objects

Every signed object uses the same envelope. The body travels as a string holding its JSON text, so a verifier checks the signature over the exact characters it received and only then parses them. Nobody ever re-encodes a signed body.

signed = {
  "t": tstr,               ; type tag, e.g. "chain-entry"
  "b": tstr,               ; the body, as JSON text
  ("k": b64-32 //          ; signer's Ed25519 public key: a device key, a recovery key, an epoch key, or a one-time reg key
   "kid": tstr),           ; or a server key ID, looked up in the signer's server document (§5.1)
  "s": b64-64,             ; Ed25519 signature
}

Exactly one of k and kid is present. The envelope is closed: a decoder MUST reject a signed with any other member. The signature covers only t and b, but an object's hash covers the whole envelope (below), so an extra member would give the same signed object a different identity: another identifier for a genesis entry, a broken prev link, or what looks like a second recovery entry. spec/vectors/signed.json has an example to reject.

The exact bytes. (The vectors in spec/vectors/ depend on them.)

  • Signers MUST write b as JCS(body). Verifiers check the signature over b exactly as received; they MUST reject a b that isn't valid I-JSON, but don't otherwise re-check its form.
  • The signature input is JCS(["tieroute/v1", t, b]). Putting the type tag inside the signature input provides domain separation: a signature over a contact request can never be replayed as a chain entry.
  • The hash of a signed object, wherever this document asks for one (identifiers, prev links, declines, reports), is SHA-256(JCS(signed)). Because b is a string, this is the same no matter how the outer object was formatted in transit, so servers may store and relay signed objects in any layout.

Verifying Ed25519 exactly. Whether a chain is valid has to come out the same in every implementation, and Ed25519 libraries disagree on edge cases. Every verifier MUST:

  • reject a signature whose scalar S isn't less than the group order L (RFC 8032 §5.1.7);
  • reject a public key A, or a signature point R, whose encoding is one of the small-order points, comparing all 32 bytes with the top bit of the last byte cleared. That's these seven encodings (the libsodium list): 00…00, 01 00…00, 26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05, c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a, ecff…ff7f, edff…ff7f, eeff…ff7f;
  • reject a public key A, or a signature point R, whose encoding isn't canonical: with the top bit of the last byte cleared, the little-endian number has to be less than p = 2²⁵⁵ − 19 (as libsodium checks);
  • check the cofactorless equation [S]B = R + [k]A.

Without the second rule, the all-zero signature with A = R = the identity point verifies against every message. spec/vectors/ed25519.json has these cases.

JWS (RFC 7515) was considered and rejected: like COSE (CBOR Object Signing and Encryption), which was rejected for v0, it has header options to get wrong, and it hides the body in base64.

4.4 Errors

Every non-2xx HTTP response carries:

error = {
  "error": tstr,     ; machine-readable code, e.g. "stale-epoch", "rate-limited", "not-allowed"
  ? "msg": tstr,     ; human-readable, for logs only
  ? "retry": uint,   ; seconds before retrying, for rate limits
  ? "ids": [* msg-id],   ; the deliveries an error is about, when a batch partly fails (§10.2)
}

The registry of error codes, with their HTTP statuses, is in the API conventions. New codes are added there.

5. Discovery

5.1 Server document

Each server publishes a document at a fixed address. This is the only fixed path in the protocol: everything else is found from here (§8.1).

GET https://{server-name}/.well-known/tieroute/server
server-doc = {
  "name":    server-name,
  "roles":   [+ tstr],            ; "home" in v1; readers ignore roles they don't know
  "apis":    [+ api-base],        ; one per supported wire version (§8.1)
  "keys":    [+ server-key],
  ? "contact": tstr,              ; admin contact (a URL), for abuse reports
  ? "handles": tstr,              ; domain this homeserver gives out handles under, e.g. "chat.example"
  ? "registration": "invite" / "closed",
  ? "invite": tstr,               ; a public registration invite code, if the server publishes one (§8.2)
}

api-base = {
  "v":      uint,                 ; wire version, 1 for this document
  ? "client": tstr,               ; absolute https URL of the client API, if role "home"
  "s2s":    tstr,                 ; absolute https URL of the S2S API
}

server-key = {
  "kid": tstr,        ; unique within this server
  "pub": b64-32,
  "nbf": timestamp,   ; not valid before
  "exp": timestamp,   ; not valid after
}
  • Servers MUST be reachable over HTTPS on port 443. The document is authenticated by TLS.
  • Readers SHOULD cache the document according to HTTP caching headers, for at most 24 hours. A reader that doesn't cache still works, but fetches the document on every call. One that caches longer can miss a key rotation (§5.1) or a moved API (§8.1) for up to a day.
  • Readers MUST keep server keys they have seen until each key's exp, so signatures made shortly before a rotation still verify.
  • Server names are always DNS names, never IP address literals. IPv6 literals contain ::, which would collide with codes (§9.4).
  • Development modes for local test networks (other ports, plain HTTP, fixed peer addresses) are implementation features, not protocol. A server in such a mode isn't conforming and MUST NOT federate with the public network. It MUST still use DNS names, such as kat.test and ravi.test (.test is reserved for testing by RFC 6761), not IP addresses.

5.2 Handles

A handle is a domain name, as in the AT Protocol: @kat.chat.example, or your own domain like @example.org. Underneath every handle is a permanent identifier (§6.2) that users never see. You can change your handle whenever you like, and contacts and groups don't notice, because they follow the identifier.

Two ways to get a handle:

  • From your homeserver. A homeserver gives each of its users a free subdomain handle, such as kat.chat.example, and serves the records below for them.
  • Your own domain. Point a domain you control at your identity with a DNS record or a static file. You don't need to run a server.

5.3 Handle resolution

A handle resolves with either of the following, and its owner publishes whichever is easier for them: a DNS record or an HTTPS document. Resolvers MUST support both. They try the HTTPS document first; if it can be fetched, it decides, and the DNS record is ignored. Only when there's no HTTPS document do they read the DNS record. Clients resolve through their own homeserver (GET resolve/{handle}).

Plain DNS can be spoofed. Someone who can forge DNS answers to a resolver can point a handle published only as a DNS record at their own identity, and their chain can name the handle too, so the check below doesn't catch it. A resolver that can validate DNSSEC (DNS Security Extensions) SHOULD, and MUST reject a record that fails validation. A handle owner who wants protection without DNSSEC publishes the HTTPS document, which TLS protects (§18.1).

DNS: a TXT record at _tieroute.{handle}:

_tieroute.example.org.  TXT  "id=tr:7f3ka7qmx2vbn4rtd6wycs5e home=chat.example"

The record's text is key=value pairs separated by spaces. id and home are required, as in the HTTPS document; resolvers ignore keys they don't know. A record split into several strings is joined first. If more than one TXT record at that name has an id, and they disagree, the handle doesn't resolve.

HTTPS: a static JSON file:

GET https://{handle}/.well-known/tieroute/handle
handle-doc = {
  "id":   identifier,
  "home": server-name,   ; hint for the first lookup; the chain is authoritative
}

To resolve:

  1. Get the id and home hint.
  2. Fetch the chain for id from home (S2S identity/fetch, or the client API through your own homeserver).
  3. If the chain says the identity has moved, follow set-home to the new server and fetch from there. The chain is signed, so a stale hint can't mislead anyone. It can only send you to the wrong place first.
  4. Check that the chain's current handle equals the handle you started with.

A handle is valid only if both directions agree: the record points to the identity, and the identity's chain names the handle (§6.4). Clients MUST check both and MUST NOT display a handle that fails either check. A handle that stops resolving is shown as "handle no longer valid", and the contact stays a contact.

Changing your handle: publish the new record, then append set-handle to your chain. Your homeserver pushes the change to your contacts. Clients SHOULD show "kat.chat.example is now example.org" once, so a rename doesn't look like a stranger. A client can skip this, but then a renamed contact looks like a stranger.

Open question: what happens to a name its identity stops using, after a rename, a move (§6.6), or an operator releasing it. It isn't settled whether the homeserver frees it, or whether a freed name waits before anyone else can register it.

5.4 Handle syntax

  • A handle is a lowercase DNS name with at least two labels. Each label is 1 to 63 characters from [a-z0-9-] and doesn't start or end with -. The whole handle is at most 253 characters. Internationalized names use A-labels.
  • Handles are displayed with a leading @, which isn't part of the handle.
  • Homeservers that give out subdomain handles MUST reserve names that could be mistaken for official identities. At least: admin, administrator, root, system, support, help, security, abuse, postmaster, hostmaster, webmaster, www, mail, api, staff, mod, moderator, official, verified, tieroute, reroute, rerouted. A server MAY reserve more.
  • Clients SHOULD warn when a handle is visually confusable with one of the user's existing contacts. A client without a contact list, such as a command-line client, can skip the warning; without it, users are more exposed to lookalike handles.

5.5 Coexisting with fedi

  • A Tieroute server can run on the same domain as a Mastodon (or other fediverse) instance. Discovery lives under _tieroute and /.well-known/tieroute/, so nothing collides.
  • There's no verified Mastodon account linking. Put links on your profile instead (§13.1).

6. Identity

6.1 Keys

Identities have no email address and no password. The protocol has no email field anywhere. Your devices are how you sign in, and a recovery code is how you get back in when you've lost them.

A user has two kinds of keys:

  • Device keys. Each device generates its own Ed25519 key pair and never exports the private half. Current devices can sign routine chain entries.
  • Recovery keys. A sequence of Ed25519 keys derived from one recovery code. They outrank every device, and none of them is kept on any device after setup. Only one is authorized at a time, and its public key stays secret until it's used (pre-rotation, §6.3).

The recovery code is 12 words: 16 random bytes (128 bits) encoded with the BIP-39 (Bitcoin Improvement Proposal 39) English word list and its checksum. The checksum catches typos when the code is typed back in. Tieroute uses only the word list and checksum, not BIP-39's seed derivation. The 16 bytes go straight into HKDF below as code. The client shows the words once at setup and asks the user to type them back.

prk               = HKDF-Extract("tieroute recovery", code)
recovery_seed_i   = HKDF-Expand(prk, "signing" || uint32_be(i), 32)     ; i = 1, 2, 3, ...
recovery key i    = Ed25519 key pair from recovery_seed_i
recovery_wrap     = HKDF-Expand(prk, "account wrap", 16)

The user only ever has the same 12 words. Each use of the recovery code spends key i and authorizes key i + 1. The client finds i by trying i = 1, 2, … up to one more than the number of recovery-signed entries in the chain, until the SHA-256 of key i's public key equals the current commitment (§6.3). If none does, the words aren't the current code. Counting alone isn't enough: after a rotation (§6.3), the count includes the old code's uses, while the new code starts again at key 1.

Devices MUST NOT keep prk, any recovery_seed_i, or recovery_wrap.

The encrypted backup has its own backup passphrase, separate from the recovery code (§7.5). recovery_wrap unlocks the account key (§7.4), so the recovery code can always get your account data and backup back, and can reset the passphrase.

There's no shared per-user identity key. Each device signs with its own key, and the recovery key outranks them all. Removing a lost device never means copying a new secret to the other devices.

6.2 Genesis and the identifier

The first chain entry is the genesis entry (seq 0, no prev). It's signed by the first device's key, and MUST contain add-device (that device), set-home, set-handle, and set-recovery committing to recovery key 1. No recovery public key appears in genesis.

identifier = "tr:" || base32(first 15 bytes of SHA-256(JCS(genesis signed object)))

The prefix is tr:, for Tieroute. That's 24 characters after tr:: shorter than a UUID, the same size as did:plc identifiers.

The identifier never changes, even when the handle, homeserver, devices, or recovery key change.

Users never see the identifier. Clients show the display name and handle. The identifier appears only in debug views and inside QR (Quick Response) codes (§6.7). Verifying a contact uses a verification code (§6.7), not the identifier.

120 bits is plenty to stop anyone taking over someone else's identifier. Making two identities of your own that share an identifier would take about 2⁶⁰ work. A client that ever sees two different genesis entries for one identifier MUST treat that identity as forked (§6.4).

6.3 Chain entries

chain-entry = {                      ; body of a `signed`, t = "chain-entry"
  ? "id":   identifier,              ; absent only in genesis
  "seq":    uint,                    ; 0 for genesis, then +1 per entry
  ? "prev": hash,                    ; hash of the previous signed entry (§4.3); absent only in genesis
  "ts":     timestamp,
  "ops":    [+ chain-op],
}

chain-op = add-device / remove-device / set-home / set-handle / set-recovery

add-device    = { "op": "add-device",    "key": device-key, ? "name": tstr }
remove-device = { "op": "remove-device", "key": device-key }
set-home      = { "op": "set-home",      "home": server-name }
set-handle    = { "op": "set-handle",    "handle": handle / null }
set-recovery  = { "op": "set-recovery",  "next": hash }   ; SHA-256 of the next recovery public key (pre-rotation)

Display names, avatars, and status messages are not in the chain. They're in your profile (§13).

Pre-rotation. The chain never holds a recovery public key. It holds a commitment, the SHA-256 of the raw 32-byte public key of the next recovery key, in a set-recovery op. How it works:

  • Using the recovery code means signing an entry with recovery key i. The signed object's k carries that key's public half, which reveals it for the first time. A verifier checks that the SHA-256 of k's 32 bytes equals the current commitment.
  • Every recovery-signed entry MUST include a new set-recovery committing to key i + 1. Once key i has been used, it's revealed, and it's never authorized again.
  • Rotating to a new recovery code is the same: sign with the old code's current key, and commit to key 1 of the new code. The new code also has a new recovery_wrap, so the account key needs wrapping under it (§7.4), without ever leaving the account data with no code that opens it. So rotation has three steps, and stopping after any of them loses nothing: (1) stage: store the account key wrapped under the new code as "recovery-next", bound to the new code's key 1 commitment, next to the old code's "recovery" wrap; (2) append the entry; (3) promote: store the new code's wrap as "recovery", and remove "recovery-next". Before step 2, the old code still works and the staged wrap is unused; a client that finds the chain never got the new commitment removes it. After step 2, the new code recovers the identity, and opens the account data from whichever of the two wraps holds it, promoting the staged one if it's still there. Every recovery wrap names the commitment to its code's key 1 (commit), so a client can tell whose it is, and a server can't pass one code's wrap off as another's.
  • Why: a quantum computer could derive a private key from a published public key. A recovery key is only published at the moment it's spent, and the key that's authorized next has never been seen, so there's nothing to attack (§3.1).
  • What pre-rotation does and doesn't protect against. It protects against a quantum attacker, who could otherwise derive a recovery private key from a published public key. It does not protect against a leaked recovery code: every recovery key comes from the same 12 words, so a leaked code leaks all of them. That's deliberate. A leaked code already means total loss (§17.3), and the alternative (a separate secret for each next key, as in KERI, the Key Event Receipt Infrastructure) would give users two codes to keep. If you think your code has leaked, rotate to a new one while you still can.
  • The remaining window: while a recovery-signed entry is in flight, its now-public key could in theory be used to forge a competing entry. That shows up as two recovery-signed branches and is handled as a compromise (§6.4.1). The window is seconds long, and it only exists against an attacker with a working quantum computer who is also watching at that moment.

6.4 Validation

A client or server validating a chain MUST check:

  1. Genesis is signed by the device key in its own add-device op, and contains a set-recovery commitment.
  2. Each later entry has seq = previous + 1, prev = the hash of the previous signed entry (§4.3), and id equal to the identifier.
  3. Each entry is signed by a key that is authorized as of the previous entry:
    • The current recovery key is whichever key k satisfies SHA-256(k) = the current set-recovery commitment. It may sign any op, and its entry MUST contain a new set-recovery (§6.3).
    • A current device key may sign add-device, remove-device, set-home, and set-handle.
    • set-recovery MUST be signed by the current recovery key. (Genesis is the one exception: it sets the first commitment.)
  4. After applying all entries, at least one device remains.
  5. Timestamps are honest. This matters for the fork rule below: without it, a thief could backdate entries to put them outside the recovery window.
    • Everyone checks that each entry's ts is at least the previous entry's ts, and rejects an entry whose ts is more than 10 minutes in the future of their own clock. A late delivery only makes an entry look older, never newer, so the future check is safe even after 72 hours of retries. It also stops a thief from future-dating the chain so nobody can ever append after them.
    • Only the homeserver accepting an append also rejects an entry whose ts is more than 10 minutes in the past. Downstream servers and clients MUST NOT apply a past check: honest entries can arrive days late.
    • Why one past check is enough: a thief's set-home has to be appended on your current, honest homeserver, which checks the clock. Every entry after it, even on the thief's server, can only have a later ts, so none of them can be dated to before the move. §6.5 is what makes that "has to": a device-signed entry counts only if the home before it appended it, or a later recovery-signed entry vouches for it. (On a first fetch, a home that no longer knows the identity is skipped, §6.5.)

The current state of an identity is the result of applying every op in order: the device set, homeserver, handle, and recovery commitment.

Appends. The homeserver serializes appends. It MUST reject an entry whose prev isn't its current head (409 stale-head), and the device retries.

6.4.1 The fork rule

A fork is two different valid entries with the same seq. It can only come from a stolen device key, a stolen recovery code, or a malicious homeserver.

The case this rule exists for: someone steals your laptop. Its device key can sign set-home and remove-device, so the thief moves your identity to their server and removes your other devices. Your fix, signed with the recovery code, then shows up everywhere else as a fork. (The rule is modeled on did:plc.)

The recovery code can undo the last 72 hours of device changes. A recovery-signed entry R at seq k MAY fork from the chain (its prev is the entry at k − 1, not the current head) if every entry it overrides, from k onward on the other branch:

  • is signed by a device key, not the recovery key, and
  • has ts no more than 72 hours before R.ts.

If those hold, R's branch is canonical. Every client and server MUST throw out the overridden entries and continue from R. If they don't hold, R is invalid as a fork. After 72 hours, device changes are final. The recovery code can still fix things after that, but only by appending new entries on top of the head: removing the thief's device, moving back with set-home, and so on. It can't erase history.

  • Which branch stands doesn't depend on which arrived first. The rule above is a comparison between two branches, and it decides the same way whichever one a client or server already holds: if it holds a recovery-signed R and then sees a device-signed branch from the same point that R couldn't have overridden (an entry in it more than 72 hours older than R), that branch is canonical and R's is thrown out. Such an R was made on a copy that missed device changes that were already final; the recovery code still outranks every device, so its holder signs a new recovery entry on top of the canonical branch. spec/vectors/fork.json has both orders.
  • Overridden entries stay discarded. A server MUST NOT accept them again, or anything built on them.
  • Two recovery-signed branches mean the recovery code itself has leaked. Clients keep the branch they saw first and show a loud warning that the identity's recovery code may be compromised.
  • A client that sees any other kind of fork MUST treat the identity as unverified and warn the user until a valid recovery entry resolves it. While an identity is unverified this way, or has two recovery-signed branches, clients MUST NOT add its new devices to groups or start a verification round with it, and SHOULD show the warning in its chats. Existing chats keep working: their members' leaves were checked when they were added (§7.1).

Alerts, so you notice within the 72 hours. When a device-signed set-home or remove-device is appended, the homeserver doing the append MUST immediately alert all of the user's current devices, including a device that's being removed, before ending its session: "Your identity was moved to evil.example by Kat's Laptop. Not you? Use your recovery code." The append happens on your current homeserver, so even a thief's move away is announced by the server you trust. Clients SHOULD keep showing the alert until it's dismissed or resolved. A client MAY stop showing it sooner only if the user can still find it, for example in a device list; an alert that disappears by itself can go unnoticed for the 72 hours that matter.

Self-rotation gets a calm notice, not the alarm. A self-rotation is an entry whose only ops are remove-device for key K and add-device for one new key K′, signed by K itself. A device uses it to replace its own key (§11.9). It can't hurt anyone else: K gives up its own place, and K′ gets exactly the access K already had. So the homeserver sends a calm notice instead of the alarm ("Kat's Laptop came back online and was re-added"). Anything else in the entry, or a self-rotation signed by any other key, gets the full alarm. A self-rotation is still a device-signed change, so the recovery code can undo it within 72 hours like any other.

New devices get a calm notice too. A device-signed add-device sends the identity's devices a chain-alert at level notice ("A new device was added: Desktop. Not you? Remove it."). Adding a device is how a phished approval takes over an identity (§7.2), and it's the change the owner can still undo by removing the device. Chats also show a system line when a member's devices change (§11.9).

Recovering in practice: on any device, or on a new device with the 12 words, the client builds R (for example, remove-device for the stolen laptop, plus set-home back to your server if it was moved), signed with the current recovery key and committing to the next one (§6.3). Your homeserver appends it and pushes the resulting chain to your contacts' homeservers (identity/push). Contacts' servers check the rule above and switch to your branch.

6.5 Publishing and caching

  • The homeserver stores the full chain and serves it over S2S (identity/fetch, Appendix A).

  • When a chain changes, the homeserver pushes the new entries to the homeservers of the user's contacts (identity/push).

  • Homeservers and clients MUST cache the chains of their users' contacts and MUST only accept updates that extend the cached chain, with one exception: a recovery-signed entry that forks from the cached chain under the fork rule (§6.4.1). It replaces the overridden entries instead of extending them. Any other update that doesn't extend the cached chain is rejected.

  • Who vouches for an entry. A device-signed entry counts only if the server that was home immediately before it (the home named by the chain up to the previous entry) appended it. That server is the one that checks its clock and alerts the user's devices (§6.4, §6.4.1). So when a server receives new entries beyond its cached head, through identity/push, identity/fetch, or a contact request:

    • a device-signed entry whose previous home is the server it came from is accepted;
    • a device-signed entry whose previous home is another server is accepted only if that server confirms it: it's in the chain that server returns from identity/fetch. If that server doesn't have it, or can't be reached, the update stops there;
    • a recovery-signed entry is accepted from any server, and it vouches for every entry before it: its prev hash commits to that whole history, and whoever holds the recovery code owns the identity. So a device-signed entry that couldn't be confirmed is accepted once a recovery-signed entry follows it, and the rules above apply again to what comes after.

    Without this, a stolen device could sign a backdated set-home and have the thief's own server push it to your contacts: your homeserver would never see it, no alarm would go out, and its old ts would already put it beyond the 72-hour fork window.

  • A first fetch is checked too. With nothing cached (a first contact, or a group member checking someone else's leaf), the same rule applies to every device-signed set-home in the chain that no later recovery-signed entry vouches for: it's confirmed with the home before it. A home that answers 404 or 410 for the identity is skipped: honest homes keep a tombstone of everyone who moved away (§6.6), so it never knew the identity. A home that returns the identity's chain without the entry fails it. A home that can't be reached fails it too, for now: the receiver accepts nothing new for that identity and asks again later. An outage, or a thief interfering with the connection, is no confirmation, and a thief with a stolen device could otherwise time a backdated move for when your homeserver can't be reached from someone else's server. A home that's gone for good never answers, so a device-signed move away from it can't be confirmed; that's what the recovery re-sign is for (§6.6). A thief can't make your real homeserver forget you, so a backdated move made behind its back is caught.

6.6 Migration

To move to a new homeserver:

  1. A device appends set-home naming the new server, on the old homeserver, which pushes it to all contacts' homeservers. The same entry sets the new handle if the old one was under the old server's domain. The old homeserver stops delivering to the identity and keeps its chain as a tombstone (below). From then on the new server is the place appends happen. The old home MUST NOT end the identity's sessions because of a move: if a thief made it, the owner's devices still have to see the alarm and recover (§6.4.1).
  2. The user registers on the new server with their existing identity (§8.2): POST register with the whole chain instead of a genesis entry. The move entry is the proof of control, signed by a device or the recovery key; the new server checks it as it would any fetched chain (§6.5), so a device-signed move has to be one the old home appended. The registration also carries the user's contact and group allowlists (§10.3), which lived on the old server and which only the user's devices know. They come in a move signed by one of the identity's current devices (as of the move entry), because the chain proves nothing about who's registering: it's public, pushed to every contact's server, and anyone who has seen it could register it first with allowlists of their own. The new server MUST check that the signer is a current device, that id, server, and head match the identity, itself, and the chain's newest entry, and that ts is within 10 minutes of its clock, and MUST take the allowlists only from the signed body.
move = {                       ; body of a `signed`, t = "move", signed by a current device of `id`
  "id":       identifier,
  "server":   server-name,     ; the server being moved to
  "head":     hash,            ; the hash of the chain's newest entry (§4.3)
  "ts":       timestamp,
  "contacts": [* identifier],  ; the contact allowlist (§10.3)
  "groups":   [* group-id],    ; the groups allowlist
}

Registering the same chain again with a valid move, because the reply was lost, succeeds, and adds that move's allowlists. 3. The new homeserver pushes the chain too. The client SHOULD also send it inside every group, so group members who aren't contacts learn about it. A client MAY skip groups whose members are all contacts, since identity pushes reach them; otherwise members who aren't contacts keep sending to the old home until they fetch the chain. 4. Contacts verify that the entry extends their cached chain and update their routing.

Ask first, and finish later. Once the old home has the move entry, it isn't home any more, so a new server that then refuses the registration (a bad invite, a taken handle, or a move it can't accept) leaves the chain naming a home with no account, and everything sent to the identity goes there. So:

  • Before appending a device-signed set-home on the old home, the client MUST ask the new server whether it would take the move: POST register with check, the chain with the entry it's about to append, and the signed move. The new server runs every check it would on the registration except asking the old home about the newest entry, which it doesn't have yet, and registers nothing. If it refuses, the client doesn't append, and nothing has changed.
  • The newest entry's age is checked by whoever first accepts it (§6.4 rule 5). For a device-signed move, that's the old home, so the new server MUST NOT refuse a registration because the move entry is more than 10 minutes old when the old home confirms it appended the entry: a registration can come late, after a lost reply or the app closing in between. A recovery-signed move, or one the old home doesn't know (404 or 410), is first accepted by the new server, and its ts MUST be within 10 minutes. The signed move is what has to be recent either way.
  • A client that has appended the move but hasn't registered MUST remember the move, and finish it, with a newly signed move, before doing anything else with the account. It finds out whether the append happened from the old home's copy of the chain, since a lost reply looks like a failure.

A small race remains: the new server can still refuse between the check and the registration, for example if someone takes the handle or the operator revokes the invite. The recovery-signed move (below) is the way out of that, as for a home that's gone.

Grace window. Deliveries the old server had already queued keep arriving after receivers learn about the move, and would fail the sender check (§10.2). For 72 hours after a set-home entry's ts, the same as the retry window (§10.5), a receiver MUST also accept deliveries for that identity from the previous home. After that, only the current home counts. The grace window covers deliveries (S2S deliver) and nothing else: anything new the previous home asks for in the identity's name, such as a key-package claim, a contact request or decline, presence, or a report, needs the current home. A queued delivery can only be a delivery, and a home the user has left has no business starting anything for them.

If the old server is gone or won't cooperate, a device-signed set-home can't be confirmed (§6.5), so the move is signed with the recovery code instead, which every server accepts from anywhere. If the user has also lost every device, the same recovery-signed entry adds a new device.

Moves have to stay confirmable. A contact who missed a device-signed move (offline, or its server down) confirms it later with the old home, so:

  • The old home MUST keep the chain of an identity that moved away, up to and including the move, and keep answering identity/fetch with it for as long as the server runs: a tombstone. It's a few kilobytes, and it's what lets a contact who missed the move, or someone meeting the user for the first time, confirm it later (§6.5).
  • An operator shutting a server down SHOULD give at least 30 days' notice, and clients SHOULD suggest signing the move with the recovery code when the server announces it's closing. Shorter notice is acceptable when a shutdown isn't planned, but then users need the recovery code to move (§6.5). A client without recovery-signed moves can skip the suggestion, at the cost of leaving its users a device-signed move that some contacts may never confirm.
  • If, after a move, deliveries to some contacts fail with wrong-home (§10.2), those contacts' servers have a copy of the chain that stops before the move and can't confirm it. The client SHOULD offer to append a recovery-signed entry on top of the chain (with only a set-recovery, re-committing as usual, §6.3). It vouches for the move (§6.5), so those servers accept the chain from the new home. A recovery-signed entry vouches for everything before it, so the client MUST show the user what they're confirming before signing. That's every device-signed set-home and add-device since the last recovery-signed entry, each with its server or device name and date, for example "Confirm your move to b.example.org on 2026-11-08." If the user doesn't recognize one, the client signs a recovery fork that removes it instead (§6.4.1), or tells the user it's past the 72-hour window and offers to undo it with new entries. A client without the recovery flow can skip the offer; those contacts then can't receive the user's messages until their servers confirm the move some other way.

If the handle is the user's own domain, they update its record's home hint, and the handle comes with them. If it was a subdomain of the old server (kat.chat.example), they pick a new handle, unless the old server keeps resolving the old one for them. Either way the identifier, contacts, and groups stay the same (§5.3).

6.7 Verification codes

A verification code proves that you and a contact share one direct chat with no one in the middle. It's 8 characters from the Crockford base32 alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ, no I, L, O, or U), shown as XXXX-XXXX. There are no emoji codes. Users start it from the contact's profile ("Verify") and compare the result on a call or in person, by reading it aloud or side by side.

Why commit-then-reveal. 40 bits is short. If an attacker in the middle could pick its own keys after seeing everyone else's, it could keep trying keys until the codes on both sides matched, which takes about 2²⁰ attempts. So every code comes from a round where both sides commit to a random value before either reveals one, the same idea as ZRTP's short authentication string. An attacker then gets one guess per round, a 1 in 2⁴⁰ chance, and a failed guess shows up as a mismatch.

A round:

  1. Each side picks a random 32-byte value r and sends verify-commit content (§12) with c = SHA-256("tieroute code commit" || r).
  2. Each side sends its verify-reveal (r) only after it has received the other side's commit.
  3. Each side checks the other's reveal against its commitment, then computes:
digest = SHA-256(JCS(["tieroute/v1", "code", "contact", transcript, reveals]))
code   = Crockford-base32(first 5 bytes of digest), formatted XXXX-XXXX
  • transcript is [sorted identifiers, base64url(MLS epoch_authenticator), heads] of the direct chat's group, where heads is the two identities' current chain-head hashes (§6.3), base64url, in the same order as the identifiers. A match therefore also confirms both sides see the same chains, which catches an identity's owner showing different contacts different branches.
  • reveals is the list of both r values as base64url strings, sorted by their raw bytes.
  • Both sides MUST be at the same epoch, or the round's codes won't match; if a commit lands mid-round, the round restarts. Each side fetches the latest chains immediately before the round, so a change that's still propagating doesn't show up as a false mismatch; if the heads differ, the clients refetch and retry once before showing a mismatch.
  • round (in the content) is a random ID chosen by whoever starts the round. Joining a round is automatic.

After a match: the user marks the contact verified. The client stores the contact's identifier as verified, and later warns if that contact's devices change in ways the user didn't see coming (for example, a recovery-key add-device).

In person, clients SHOULD also offer a QR code holding the full identifier, so a scan compares all of it with no round needed. A client without a camera or a display can skip it; its users then rely on the code round.

The identifier stays hidden (§6.2). Clients MUST NOT show a truncated identifier as a security check; that's what verification codes are for.

7. Devices

7.1 Device keys and MLS

Each device's Ed25519 device key is also its MLS signature key. A device's MLS credential is a BasicCredential whose identity field is JCS(mls-identity):

mls-identity = [identifier, device-key]

When a client sees a leaf, it MUST check that the leaf's signature key equals the device-key in the credential and that this key is a device in the identity's chain, as of the commit's time (below). A leaf that fails these checks is an impostor, or a revoked device, and the client MUST NOT accept a commit that adds it.

Which devices count, and when. A commit the hub has sequenced has to get the same verdict from every member, whatever the age of their copies of the chains it depends on, or the members who reject it are stuck in the old epoch while the rest move on. So every check of a device against a chain in a sequenced commit (an added leaf here, an add approval's signer in §11.8, a Remove by a non-admin in §11.9) is made as of the commit's hub receipt time T (§11.6), not as of whenever a member happens to look:

  • Chain evidence. The check uses the identity's chain as its home serves it, fetched at or after T by the member's clock (GET chain/{id} with fresh, which a homeserver MUST answer with an error, not its cache, when it can't reach the identity's home or won't fetch it). A copy fetched at or after T can be reused for later commits. Entry timestamps decide, not arrival order. This assumes clocks within 10 minutes of each other, as §6.4 does: a home only appends entries dated within 10 minutes of its clock.
  • A device counts at T when an add-device for its key is dated no later than T + 30 minutes, and no remove-device for it after that is dated before T - 1 hour. The margins cover the member's, the hub's, and the home's clocks each being up to 10 minutes off: every removal dated an hour before T was appended well before the member fetched, so every copy fetched at or after T has it. Every add-device an honest commit uses was appended before the commit. A key that was never added never counts: that leaf is an impostor.
  • So a removal takes effect for group authorization an hour after its date. Until then, the removed device can still sign an add approval, or be added as a leaf. Members still commit the Remove of its leaf as soon as they see the removal (§11.9), so a leaf added in that window is removed again.
  • A device is revoked at T, for a Remove by a non-admin, when a remove-device for it is dated no later than T + 30 minutes, with no add-device for it after that.
  • Missing or conflicting state. If a member can't get a fresh copy (the home is unreachable, or rate-limits it), or its copy conflicts with what it saw before (a fork it can't resolve, §6.4.1), it MUST NOT decide either way: it keeps the commit unapplied, and tries again later. It doesn't reject, since another member with a working connection would accept; and it doesn't accept on an old copy, since the copy may be missing a removal. Until then it can't follow the group's changes. A commit that's still undecided after 24 hours counts as a failing group for the re-create offer (§11.11).
  • What this assumes. The verdict is the same for every member only if clocks are within 10 minutes (members go by their homeserver's clock, §8.4); the identity's home serves the same chain to all of them (a home showing different members different branches is equivocation, caught by verification, §6.7); the identity itself doesn't append entries dated around T after the commit, which could only confuse members about its own devices; and the hub's receipt time is honest: a hub working with the holder of a revoked key could date a receipt before the revocation. A recovery fork (§6.4.1) can also discard an add-device that a commit already used; members who saw the fork reject the commit, and the group needs re-creating (§11.11).

7.2 Linking a new device

A new device is linked by a device that's already signed in. Nothing is typed but codes, and no password exists.

  1. The new device generates its device key and a one-time hybrid HPKE key pair (§3), and parks the HPKE public key on the user's homeserver (no session is needed). The hybrid public key is about 1.2 KB, too big for a comfortable QR code, so the link code carries [device-key, SHA-256(hpke-public-key), home]. The new device shows it as a QR code and as text.
    • The user enters their handle on the new device, which finds the identity and its home from it: the new device needs the identifier to sign in once it's added, and the link bundle can't arrive before then. Parking the key is a link proof without a code, carrying the key (POST link/proof, spec/api/client-public.md); only the identity's own devices can fetch it.
    • As text, the link code is link-, then the device key and the hash in lowercase base32 in groups of four, then @ and home.
  2. The existing device reads the code, fetches the full HPKE key from home, and checks it against the hash.
    • Scanned in person (QR): being in the room is the authentication. Go to step 3.
    • Typed (text code): the existing device shows a confirmation code, XXXX-XXXX (40 bits, Crockford base32, as in §6.7), which the user types on the new device. The new device signs it with its device key and sends it back (a link proof). Only a proof that matches lets linking continue. This raises the bar for "read me the code" phishing, since an attacker now needs a code from each device, but a live social engineer can still ask for both. So before showing a confirmation code, the existing device MUST warn: "Only continue if you're setting up your own device, right now. Your app will never ask you for this code." The real safety net is the new-device notice (§6.4.1): the owner can remove an unwanted device. The parked HPKE key and link proofs are unauthenticated, so homeservers MUST rate-limit both per identity and per source address.
  3. The existing device appends add-device to the chain.
  4. The existing device sends the new device a link bundle, encrypted with HPKE to the one-time key and delivered through the homeserver (delivery kind "link"). It tells the new device which identity it joined, and carries the contact list, the group list, the account key (§7.4), and optionally a history transfer (§7.3).
    • The delivery's to is the user's own identifier, and its data is the HPKE encapsulation (1,120 bytes) followed by the ciphertext of JCS(link-bundle), with info = "tieroute link bundle" and the identifier as associated data. A homeserver accepts a "link" delivery only from a user to themselves (§10.1). The new device opens it after signing in; other devices can't, and ignore it.
  5. Every group the user belongs to gains the new device (§11.9).
link-bundle = {
  "id":       identifier,           ; the identity the new device has joined
  "contacts": [* identifier],
  "groups":   [* group-id],
  "account-key": b64-16,            ; §7.4
  ? "history": [+ media-ref],       ; §7.3: the history export, split into files of at most 64 MiB
}

7.3 History

New devices can get history two ways: directly from another device when linking, and from an optional encrypted backup.

  • Device to device. During linking, the existing device exports history, encrypts it as a media blob (§14), and puts the reference in the link bundle. The homeserver only ever stores ciphertext, and only until the new device fetches it.
  • Encrypted backup. See §7.5. This is for users who lose every device.
  • The history format is shared by both paths. It's JSON Lines: one history-item per line, UTF-8, each line ending in \n, in time order: one header, an item per chat, and an item per message. A message item holds the original signed app-message (§12), exactly as it was sent or received, so imported history can be verified and can't be forged in transit.
; A history export is JSON Lines of history-item: the header first, then the rest in time order.
history-item = history-header / history-chat / history-msg

history-header = { "t": "history", "v": 1, "id": identifier, "made": timestamp }

history-chat = {
  "t":    "chat",
  "chat": identifier / group-id,    ; the other person in a direct chat, or the group
  "kind": "direct" / "group",
  ? "name": tstr,                   ; the group's name at export time
}

history-msg = {
  "t":    "msg",
  "chat": identifier / group-id,    ; refers to a history-chat
  "msg":  signed,                   ; the app-message (§12)
}
  • Edits, deletes, and reactions are ordinary messages, applied in order on import.
  • Left out: attachment bytes (their media-refs stay in the messages, but the files expire after 30 days, §14), typing and read receipts, call signaling, and anything the client shows on its own, like system lines.
  • On import, a client MUST verify each message's signature, and that its signing key appears as a device somewhere in the sender's chain (it may have been removed since). Messages that fail are dropped. For groups, the message's group MUST match the chat, or the message is dropped; for direct chats, any group with that person counts, since a direct chat can be re-created (§11.11).
  • Device to device: the export is encrypted as media (§14), split into files of at most 64 MiB, and listed in the link bundle's history (§7.2).

7.4 Account data

Your contact folders, nicknames you give contacts, and client settings are stored on your homeserver, so they survive losing every device.

They're stored encrypted anyway. Encrypting costs nothing, and it keeps "who you file under what" away from server admins.

  • Every identity has a random 16-byte account key, created at setup. Devices keep it, and new devices get it in the link bundle (§7.2).
  • The homeserver stores the account key wrapped:
    • always under recovery_wrap (§6.1)
    • also under the backup passphrase, if one is set (§7.5)
  • Account data is one AES-128-GCM blob under the account key (GET account/data, POST account/data), with a random 12-byte nonce prepended. Each write carries the version it replaces. The server rejects a stale write with 409 stale-version, and the client merges and retries.
  • Both kinds of blob are bound to their identity and purpose with associated data, as backup segments are (§7.5). A wrapped key uses JCS(["tieroute account key", identifier, via]), or JCS(["tieroute account key", identifier, via, commit]) when it has a commit (a recovery wrap, §6.3). Clients MUST give new recovery wraps a commit, and MUST still open an older one without it. Account data uses JCS(["tieroute account data", identifier, version]), where version is the version the blob has once stored: the write's replaces + 1. A blob served for another identity, or labeled with another version, fails to decrypt, and the client MUST treat that as the server misbehaving, not as data to repair. A server can still serve an older version under its own older number; §7.5 describes that limit. spec/vectors/account.json has an example of each.
  • Getting back in after losing every device: sign in with the recovery code, unwrap the account key, and your contact folders and settings come back. The contact list itself also comes back, from your homeserver (§9).
account-data = {                    ; plaintext of the account data blob, as JSON
  "folders":   [* { "name": tstr, "members": [* identifier] }],
  "nicknames": { * identifier => tstr },
  "settings":  any,                 ; client-defined, opaque to the protocol
  ? "backup":  { "device": device-key, "seq": uint },   ; the backup device and its newest segment (§7.5)
}

wrapped-key = {
  "via":  "recovery" / "recovery-next" / "passphrase",   ; recovery-next: a rotation's staged wrap (§6.3)
  "ct":   b64,                      ; AES-128-GCM of the account key, random nonce prepended
  ? "argon2": { "salt": b64-16, "m": uint, "t": uint, "p": uint },   ; for "passphrase"
  ? "commit": hash,                 ; for "recovery" and "recovery-next": SHA-256 of the code's recovery key 1 (§6.3)
}

7.5 Encrypted backup

  • Message history backup is opt-in, and unlocked by a backup passphrase that is separate from the recovery code.
  • The backup is encrypted with the account key (§7.4). Turning the backup on adds a passphrase-wrapped copy of the account key on the homeserver (POST account/keys). The key is derived from the passphrase with Argon2id (§3), and the salt and parameters are stored next to it.
  • Forgot the passphrase? Use the recovery code to unwrap the account key and set a new passphrase. Changing the passphrase only re-wraps the account key; the backup itself isn't re-encrypted.
  • The passphrase only unlocks data. It can't add a device or sign anything. The wrapped key sits on the homeserver, so a malicious homeserver can make unlimited offline guesses at the passphrase. If the passphrase could also take over the identity, a weak passphrase would mean a stolen identity. A new device gets in by scanning a QR code from an existing device (§7.2) or with the recovery code.
  • Because of those offline guesses, clients MUST reject a passphrase unless the zxcvbn strength estimator scores it 4 (about 10¹⁰ guesses or more), and SHOULD offer a generated six-word passphrase (about 77 bits) as the default. A client MAY instead start with an empty field, as long as it enforces the zxcvbn check; a generated default is what keeps most users comfortably above it.
  • Segments. Backup data is stored on the homeserver as append-only segments (backup/put, backup/list, backup/get). Each segment's plaintext is a run of history-item lines (§7.3); segment 0, and every snapshot, starts with the header. A segment holds at most 1 MiB of plaintext. Clients upload one when that much has built up, or once a day, whichever comes first.
  • Encryption. backup-key = HKDF-Expand(HKDF-Extract("tieroute backup", account-key), "segments", 16). Each segment is AES-128-GCM under backup-key with a random 12-byte nonce, and associated data JCS(["tieroute backup", identifier, seq]), so a server can't reorder segments or swap them between identities.
  • Compaction. A client MAY upload a snapshot: a segment holding the whole history so far, marked snapshot. Once it's stored, the server deletes every segment with a lower seq. Restoring means fetching the newest snapshot and every segment after it.
  • Quota. Each server sets its own. An upload over it gets 413 over-quota; the client compacts, or tells the user the backup is full.
  • Attachments aren't backed up in v1, only message history.
  • One writer. Each identity has one backup device, named in its account data (§7.4). Only it uploads segments, so two devices with different histories never collide on seq. Another device takes over by updating the account data.
  • Truncation shows. After each upload, the backup device records the newest seq in the account data, which the server can't read or forge. A restore that finds fewer segments than that warns the user that the server lost or withheld some. The check has a limit: a user restoring after losing every device has no copy of the account data, so a server that serves an old identity-data blob along with old segments goes unnoticed (§18.1).
backup-put-req  = { "seq": uint, "ct": b64, ? "snapshot": true }   ; ct = nonce || AES-128-GCM output
backup-list-res = { "segments": [* { "seq": uint, "size": uint, ? "snapshot": true }] }

7.6 Removing a device

A device is removed by appending remove-device, signed by another current device or the recovery key. After that:

  • One of the user's remaining devices MUST commit a Remove of that device's leaf in every group.
  • Any other group member that sees the chain update SHOULD commit the Remove if it hasn't happened within 60 seconds (§11.9). A member that's offline or can't reach the hub skips it; until someone commits the Remove, the removed device can still read new messages.
  • The homeserver MUST end the device's sessions and stop delivering to it.

8. Transport

8.1 Two APIs, found from the server document

API Between Base URL Auth
Client API a device and its own homeserver the server document's apis[].client (§5.1) Bearer session token
S2S API two servers the server document's apis[].s2s (§5.1) Signed request bodies (§8.3)

Follow your nose. Apart from the server document itself, no path is fixed by the protocol. A client or server fetches https://{server-name}/.well-known/tieroute/server, picks the entry in apis for the highest wire version both sides support, and resolves every endpoint relative to that base: POST register means POST {client}register. An operator can therefore put the APIs anywhere, including on another host, or run several versions side by side.

  • Base URLs MUST be absolute https:// URLs ending in /.
  • Implementation note: rerouted's defaults are https://{name}/.well-known/tieroute/v1/client/ and https://{name}/.well-known/tieroute/v1/s2s/, which keep Tieroute out of the way of anything else served on the domain.

Both APIs use HTTPS and JSON bodies. Appendix A lists every endpoint. The exact request and response formats are in the client API and server-to-server API references. The API conventions have the rules every endpoint shares.

8.2 Client API authentication

  • Registration is by invite. A server's registration is invite or closed. POST register takes a genesis entry (or the current chain, when migrating, §6.6) and an invite code. A migrating chain's newest home MUST be this server. Registration never asks for an email address.
  • Open registration is a public invite. A server that wants anyone to sign up publishes a public invite code in its server document (invite, §5.1), on its website, or both. Closing registration means revoking that code. Anything beyond this, such as a captcha, is the server's own business.
  • Sign in: POST auth/challenge takes { "id", "key" } and returns a nonce. POST auth/session takes { "auth" }, a signed object with type "auth", signed by the device key (k), over { "id", "nonce", "server": server-name, "ts" }. It returns a session token.
  • The server MUST check that the signing key is a current device in the chain.
  • Real-time stream: GET stream upgrades to a WebSocket. The server pushes new inbox items and presence changes as JSON text frames. Clients that can't hold a WebSocket open can poll GET inbox instead.

8.3 S2S request signing

Every S2S call is a POST whose body is a signed object with type "s2s", signed by one of the sending server's keys (kid):

s2s-request = {
  "from":  server-name,
  "to":    server-name,
  "path":  tstr,          ; the endpoint relative to the receiver's S2S base, e.g. "deliver"
  "ts":    timestamp,
  "nonce": nonce,
  "body":  any,           ; the endpoint-specific request
}

The receiver MUST check that:

  1. to is its own name and path matches the endpoint the request arrived at.
  2. ts is within 5 minutes of its clock.
  3. It hasn't seen nonce from from in the last 10 minutes.
  4. The signature verifies with a key in from's server document, fetched from https://{from}/.well-known/tieroute/server.

That's the whole request-signing scheme. There are no HTTP Signatures and no header canonicalization: the body carries everything, so there's one way to get it right.

Responses are plain JSON, authenticated by TLS.

8.4 Clocks

The homeserver is the clock. Timestamps run through the protocol (chain entries in §6.4, sign-ins, moves in §6.6, S2S requests in §8.3, hub receipts and the device timing of §7.1), and every check of them assumes clocks within minutes of each other. So:

  • Servers MUST keep their clocks synchronized to real time, with NTP or similar, from any source they trust. A server more than 5 minutes off can't federate (§8.3).
  • Clients SHOULD use their homeserver's time, not the device's, for every timestamp they sign and every check that depends on the time. POST auth/challenge returns the server's time in now; the client keeps the difference, measured against the middle of the request, until it next signs in. A wrong device clock then breaks nothing, and the clocks that matter are servers', which federation already keeps within 5 minutes of each other.
  • A request whose timestamp is too far from the server's clock is refused with clock-skew, and the error carries the server's time in now, so the client can correct for it and try again. A client registering for the first time has no other way to learn it. The error's msg SHOULD say how far off the request was, and in which direction.
  • A client SHOULD tell the user when the device's clock is more than a minute from its homeserver's, and that it's the device's clock to fix. It only warns: it keeps working on the server's time.
  • A server SHOULD tell its operator when its own clock looks wrong: when it refuses a request for clock-skew, when a peer refuses one of its requests for it, and when a peer's clock (from the Date header of its responses) is more than a minute from its own. When clients or peers are off by about the same amount, it's the server's clock that's wrong.

Why not have clients check the time with an NTP server: plain NTP isn't authenticated, so it doesn't stop an attacker; it would send every user's address to a time service; and some networks block it. The homeserver's time comes over TLS, from a server the client already relies on. The time doesn't need encrypting or signing beyond that: it isn't secret, and TLS already proves which server it came from. A homeserver that lies about the time gains little, since it already checks the timestamps of the entries it appends (§17.2), and the warning shows the user a large lie.

9. Contacts

Nobody can reach you until you've accepted them as a contact. This section defines contacts; §10 defines how messages travel between them, and how your homeserver uses your contact list to drop everything else.

Your homeserver keeps your contact list: the identifiers you've accepted, plus anyone you've sent a request to and are waiting on (pending outgoing). It uses that list as an allowlist (§10.3).

9.1 Invite codes

Every contact request needs an invite code. There's no way to request someone from their handle alone. You hand out codes to the people you want to hear from, and nobody else can even ask.

  • Invite codes are created on your homeserver (POST invites/create), each with an optional use count and expiry. They're 10 case-sensitive characters from [A-Za-z0-9] (about 59 bits). The homeserver stores only HMAC-SHA256(server-secret, code), with a secret of its own that isn't in the database, so a leaked database doesn't let anyone brute-force the 59-bit codes.
  • A public invite on your profile. You can publish one invite code in your public profile (invite, §13.1). Anyone who can see your profile can then send you a request. It's how you make yourself reachable by anyone who knows your handle, and deleting it from your profile and revoking it closes that door again.
  • A code is created as public or private. Requests that use a public code are held to the per-sender rate limits (§9.5). Private codes are limited by their own use counts instead, since they only reach the people you gave them to.
  • Revoking a code (POST invites/revoke) stops new requests with it. Contacts already made with it stay.

9.2 Contact requests

A contact request carries no message. Any text would have to be plaintext, because there's no encrypted channel yet. It's a small signed object:

contact-request = {               ; body of a `signed`, t = "contact-request", signed by a device of `from`
  "from":   identifier,
  "to":     identifier,
  "ts":     timestamp,
  "nonce":  nonce,
  "invite": tstr,                 ; the invite code, without the "@handle::" part (§9.4)
}

Flow:

  1. Kat's client resolves Ravi's handle, fetches his chain, and sends the signed request to her homeserver (POST contacts/request).
  2. Kat's homeserver adds Ravi to Kat's contact list as pending outgoing (so Ravi's acceptance can get through) and forwards the request to Ravi's homeserver (S2S contacts/request), along with Kat's chain.
  3. Ravi's homeserver checks the invite code, the rate limits, and Kat's chain, then puts the request in Ravi's inbox. A request without a valid code is dropped silently.
  4. If Ravi and Kat have both sent each other requests, it's treated as accepted on both sides. The one whose identifier sorts first, bytewise, creates the direct chat, so there's only one.

9.3 Accepting and declining

  1. Ravi's device adds Kat to Ravi's contact list (POST contacts/accept).
  2. Ravi's device claims one key package for each of Kat's devices (S2S keypackages/claim, which Kat's homeserver allows because Ravi is pending outgoing for Kat).
  3. Ravi's device creates a direct chat, a 1:1 MLS group (kind "direct", §11.3) with Ravi's homeserver as hub, adds Kat's devices and his own other devices, and sends the Welcome.
  4. Kat's homeserver sees a Welcome from a pending-outgoing contact, promotes Ravi to a full contact, and delivers it.

Why the accepter claims the key packages: strangers can never drain your key packages, because nobody can claim them until you've asked to be their contact.

Pending requests expire. A pending-outgoing entry lasts 30 days. After that it no longer lets the other person claim key packages or send a Welcome, so someone who ignored a request can't use it much later to pull the requester into a group.

Declining is shown. If Ravi declines, Kat sees "Ravi declined your request":

contact-decline = {               ; body of a `signed`, t = "contact-decline", signed by a device of `from`
  "from":    identifier,          ; the one declining
  "to":      identifier,          ; the original requester
  "request": hash,                ; hash of the request's signed object (§4.3)
  "ts":      timestamp,
}

Ravi's device sends it with POST contacts/decline, and Ravi's homeserver forwards it with S2S contacts/decline. Kat's homeserver accepts it only if it matches a pending outgoing request, then clears that request and tells Kat's devices. After a decline, Kat can't send Ravi another request for 30 days, even with a new code.

Blocking stays silent (§9.6). To the person who's blocked, a block looks like a request that stays pending.

Codes use the :: format: an address, then ::, then the code. A leading @ marks a person, the same way handles are displayed (§5.4):

You paste It means
@example.org::Kx82mPq1Za Send a contact request to that handle, with that invite code
@example.org Show that person's public profile. If it publishes an invite code (§9.1), offer to send a request with it
  • Clients split at the first ::. Handles and server names can't contain :: or @, so this is always unambiguous.
  • Clients MUST treat a code with an unknown form as an error, not guess. Later versions add forms (community servers use server.example::code).

Links are ordinary https:// links on the project's link domain, rerouteholdings.llc. Any code becomes a link by putting https://rerouteholdings.llc/ in front:

https://rerouteholdings.llc/@example.org::Kx82mPq1Za

Links are stateless: everything is in the link itself. The link domain only serves a page that hands the code to the app (or tells you to install it), and stores nothing. If the link domain is down, pasting the code or the whole link into the app still works.

9.5 Rate limits

Receiving homeservers MUST rate-limit contact requests. Suggested defaults (servers can tighten them):

Limit Default
Requests from one identity to one target 1 per 7 days, or 1 per 30 days after a decline
Requests from one identity, total 20 per day
Requests from one origin server to one user 10 per day
Requests from one origin server, total Server's choice; SHOULD be much lower for servers it has never federated with (see below)
Failed invite codes from one origin server 20 per hour, so codes can't be guessed

Be honest about what these do. Identities are free: anyone can mint a new one on their own server in seconds. Invite codes are the main defense: without one, a request goes nowhere. The limits matter for public codes, which anyone can read off a profile. There, the per-identity limits stop casual pests, and the per-origin-server limits are the real defense, because a server needs a domain, and domains cost money. That only holds if "origin" means the domain someone paid for, not the server name: one domain can have as many server names under it as its owner wants. So receiving homeservers MUST count per-origin limits by registrable domain (the name one label below a public suffix, from the Public Suffix List), not by full server name. Shared hosts that give out subdomains to different people are on that list, so their users still count separately. Receiving homeservers SHOULD set tight limits for servers they've never federated with, and loosen them as a server builds a history of accepted requests. A server that federates only with servers it already knows can skip this; an open server without it is the most exposed to request spam.

Servers don't share reputation or blocklists about other servers (§16.4). Each server sets its own limits.

9.6 Removing and blocking

  • Remove: POST contacts/remove takes the identity off the contact list. The client leaves the direct chat.
  • Block: removes the contact and also makes the homeserver drop every future request and Welcome from that identity, whatever code it carries. Blocking isn't announced to the blocked user.
  • Contact folders and nicknames are stored on the homeserver as encrypted account data (§7.4), so they sync between devices and survive losing them all.

10. Delivery

Servers can see who talks to whom (§18.1), but never what's said. Strangers can't add you to groups. Inbox retention is 30 days, and delivery retries last 72 hours.

10.1 Deliveries

A delivery is the unit servers route. Servers read its fields; they never read data.

delivery = {
  "id":    msg-id,           ; random; used to deduplicate
  "kind":  delivery-kind,
  "from":  identifier,       ; sending identity (see §18.1 for the metadata cost)
  "to":    [+ identifier],   ; recipients on the destination server
  ? "group": group-id,       ; required for kind "mls"
  ? "epoch": uint,           ; required for kind "mls"
  ? "mls":   mls-kind,       ; required for kind "mls"
  ? "receipt": signed,       ; commit-receipt from the hub; required when mls = "commit" (§11.6)
  ? "ephemeral": bool,       ; true: deliver only to connected devices, don't store or retry (§13.3)
  "data":  b64,
}

delivery-kind = "mls"        ; data = MLSMessage (PrivateMessage only) or Welcome
              / "link"       ; data = HPKE-encrypted link-bundle (§7.2)
              / "chain"      ; data = JSON array of signed chain entries (§6.5)

mls-kind = "app" / "proposal" / "commit" / "welcome"
  • Kind "mls" is the only way to carry chat content. Servers MUST reject "mls" deliveries that are missing group, epoch, or mls.
  • Kind "link" goes only from a user to their own identity: to is exactly the sender. It carries a link bundle to the user's new device (§7.2), and never leaves the homeserver.
  • The sending client fills in group, epoch, and mls from the MLS message it created. If a client lies, other clients reject the bad MLS message, but the hub may already have advanced the epoch. That can freeze the group (§11.6).
  • Clients MUST send all MLS handshake and application messages as PrivateMessage. PublicMessage is not used.

10.2 Routing

  1. The sending device submits deliveries to its homeserver (POST send). For an MLS message to a group, to lists every member identity (the client knows them from the MLS tree).
  2. The sending homeserver groups recipients by their current homeserver (from cached chains) and makes one deliver call per destination server per batch, with to narrowed to that server's users.
  3. Commits are sequenced by the hub before the sending homeserver fans them out (§11.6).
  4. The receiving homeserver checks each delivery against the recipient's allowlists (§10.3) and appends it to each recipient's inbox.

The sending homeserver MUST check that from is one of its own users, and the receiver MUST check that from's chain names the sending server as its home. Commits satisfy this too: the hub only sequences them, and the committer's own homeserver delivers them (§11.6). This stops a server from injecting messages "from" users who live elsewhere.

When the sender check fails, the receiver says so. If, after re-fetching the sender's chain from the calling server (§6.5), the receiver still doesn't see that server as the sender's home (or previous home within the grace window, §6.6), it delivers the rest of the batch and answers 409 wrong-home, listing the failed delivery IDs in ids. Deliveries not listed in ids were delivered, or dropped silently by an allowlist (§10.3), exactly as with 202; the sender MUST NOT treat them as failed or retry them. Unlike allowlist drops (§10.3), this reveals nothing about the recipient, only that the receiver's copy of the sender's chain is behind. It's a refusal, so the sender doesn't retry; it tells the sending device (delivery-failed with reason wrong-home), which is what prompts a recovery-signed re-sign after a move (§6.6).

10.3 Allowlists

Homeservers enforce the "contacts only" rule with two per-user allowlists:

  • Contacts: the user's contact list (§9), including pending outgoing requests.
  • Groups: group IDs the user's devices have joined. A homeserver that puts a Welcome in a user's inbox adds its group to their groups allowlist at the same time. Members start sending as soon as the Add is accepted, and the new member's device may not process the Welcome for hours, so without this everything sent in between would be dropped. A device also registers each group with POST groups/register after processing a Welcome (a no-op if the Welcome already did), and unregisters on leaving.

A receiving homeserver MUST drop an "mls" delivery unless:

  • mls is "welcome" and from is in the recipient's contacts, including pending outgoing (§9.3). You can only be added to a group by a contact. Or:
  • group is in the recipient's groups allowlist.

Deliveries of kind "link" are accepted only from the recipient's own devices, and "chain" only from contacts and the user's own homeserver.

Rejections are silent: the sender gets 202 Accepted either way, so nobody can probe allowlists.

Group IDs aren't secret. They travel in the clear past every member's homeserver, and removed members keep them. So anyone who knows a group ID can send deliveries that pass the groups allowlist. Clients drop them (they aren't valid MLS from a member), so nothing can be read or forged, but they cost inbox space. Receiving homeservers MUST rate-limit "mls" deliveries per sender and group, and per origin server and group, since identities are free and servers aren't (§9.5), and drop deliveries from identities the recipient blocked. The hub's commit/fetch returns only encrypted commits, so it needs no access check beyond rate limits. Open question: whether to close this fully with a per-epoch delivery key that members sign deliveries with, as epoch keys do for commits (§11.6).

10.4 Inbox

  • Each user's inbox holds deliveries in arrival order, each with a server-assigned cursor.
  • Deliveries marked ephemeral (§13.3) skip the inbox entirely: they go only to devices connected right now.
  • If deliveries expired before a device acked them, the inbox response says so ("gap": true), and the device follows §11.9 to rejoin its groups.
  • Devices fetch from a cursor (GET inbox?after=…) or receive deliveries live over the stream, then POST inbox/ack with the highest cursor they've processed.
  • A delivery is removed once every current device has acked it, or after 30 days, whichever comes first.
  • Quotas. Each server sets its own per-user inbox quota. When a recipient's inbox is full, the receiving server answers deliver with 503 unavailable and a retry delay. The sender keeps retrying (§10.5) while the recipient's devices drain the inbox, and only then tells the sending device the delivery failed. Nothing is dropped silently. (A sender can tell the recipient is backed up; that's accepted.)

10.5 Retry

A sending homeserver keeps each failed delivery in an outbox and retries it with exponential backoff:

  • Retries back off: each wait is longer than the last, up to a cap, so a server that's down isn't hit every few seconds for three days.
  • The schedule. The wait before retry n (counting from 1) is min(10 s × 2^(n−1), 1 hour), multiplied by a random factor between 0.75 and 1.25 so many senders don't retry in lockstep. That's retries after about 10 s, 20 s, 40 s, … reaching the 1-hour cap after about 9 attempts, then hourly: roughly 80 attempts over 72 hours.
  • One schedule per destination. A server that's failing gets one backoff schedule for all deliveries queued to it, not one per delivery, and a success resets it. New deliveries to a server that's in backoff wait their turn instead of trying immediately.
  • Server hints win. A 429 or 503 with retry means wait at least that long, even if the schedule says sooner.
  • Which failures are retried. Network errors, 5xx, 408, and 429 are temporary. Every other 4xx is a refusal: the delivery is dead at once and isn't retried. A 409 wrong-home refuses only the deliveries listed in its ids (§10.2); the rest of the batch got through.
  • When a delivery is dead. A delivery that hasn't succeeded 72 hours after it was first submitted, or that was refused, is dead. The homeserver drops it and tells the sending device with the stream event delivery-failed, which names the delivery id and the recipients it didn't reach.
  • Receivers deduplicate on id for at least 7 days, so a retry after a lost response is harmless.

11. Groups and MLS

11.1 MLS profile

Setting v1 value
Ciphersuite The hybrid ML-KEM-768 + X25519 suite with Ed25519 signatures only (§3, §3.1)
Credential type basic only (§7.1)
Wire format PrivateMessage for everything; PublicMessage not used
Proposal types Add, Update, Remove, GroupContextExtensions
Authenticated data Empty
Not used PSK (pre-shared keys), ReInit, ExternalInit, external commits, external senders
Extensions tieroute_group (required, §11.2), and required_capabilities naming it; no others

One ciphersuite, no optional extensions, a minimal proposal set. MLS is only for private chats. The proposal set is Add, Remove, Update, and GroupContextExtensions, always carried by value inside a commit. One exception: leaving a group. MLS doesn't let a member commit its own removal, so a leaving device sends a standalone Remove proposal for itself (mls: "proposal", §10.1), and any remaining member commits it. No other standalone proposals are used. Every mainstream MLS library supports this proposal set.

Capabilities. Every leaf node and key package MUST list 0xF0A1 (§11.2) in its capabilities' extensions, since a GroupContext extension can only be used when every member supports it. Every group group's GroupContext MUST also carry a required_capabilities extension listing exactly 0xF0A1, with no proposal or credential types, from creation on and in every GroupContextExtensions update. OpenMLS refuses a GroupContextExtensions proposal that sets a non-default extension the group doesn't require, and changing tieroute_group is how admins, adds, and leaving work (§11.8). Clients accept direct groups created without it; those never change tieroute_group.

11.2 The group extension

This section defines the tieroute_group extension.

A GroupContext extension carrying the group settings the protocol needs. Its type is 0xF0A1 (from MLS's private-use range), and its data is JCS(tieroute-group). Note: 0xF0A1 is provisional. A permanent IANA code point hasn't been requested yet, so the type can change.

tieroute-group = {
  "kind":   "direct" / "group",
  "hub":    server-name,
  "admins": [* identifier],      ; empty for "direct"
  "since":  { * identifier => uint },   ; epoch at which each member identity joined (§11.8)
  ? "replaces": group-id,        ; set when this group re-creates an older one (§11.11)
  ? "used": { * msg-id => timestamp },   ; add-requests whose approval was used, with its exp (§11.8)
  ? "approval": signed,          ; the add approval for the commit that set it (§11.8)
}

Group name, topic, and avatar are not here. They're in encrypted application messages (§12), so only members see them.

11.3 Kinds of groups

Kind Members Created by
direct Exactly two identities (and all their devices) The accepter of a contact request (§9.3)
group 2 to 25 people (§11.10) Any user

11.4 Key packages

  • Each device keeps 50 key packages uploaded on its homeserver (POST keypackages/upload), plus one last-resort key package that can be reused.
  • keypackages/claim (S2S) returns one key package per current device of the target identity. A key package is never handed out twice, except the last-resort one, which is used only when the others run out.
  • A homeserver MUST refuse a claim unless the claiming identity is a contact of the target, or the target has a pending outgoing request to the claimant (§9.3), or the claim names a group in the target's groups allowlist (for re-adding a device, §11.9). Group IDs aren't secret (§10.3), so claims that name a group MUST be rate-limited per claimant and target, and per origin server and target (for example, 10 a day each), so a removed member, with however many identities, can't drain anyone's key packages. Claims by contacts MUST be rate-limited per claimant and target too (rerouted: 50 a day): starting chats takes a few, and each one uses up a one-time key package, after which new chats reuse the last-resort one.
  • Devices SHOULD replace the last-resort key package soon after it's used. A device that's offline can't, and that's acceptable for a while; until it does, new chats with it reuse the same key package, which weakens their initial forward secrecy.

11.5 Creating a group

  1. The creator's device creates the MLS group with a random 32-byte group-id and the tieroute_group extension, with hub set to the creator's homeserver.
  2. It registers the group with the hub (§11.6) before sending anything. If the hub answers 409 already-exists, the ID is taken: the device discards the group, picks a new random ID, and starts again. With 256-bit random IDs this only happens through a bug or a broken random number generator, but clients MUST handle it rather than fail.
  3. It claims key packages for each invitee's devices (they MUST be the creator's contacts; a homeserver refuses the claim otherwise, §11.4) and for its own other devices.
  4. It sends the Add commit to the hub, then sends the Welcome to the new members.

11.6 The hub: commit sequencing

What a hub is. MLS needs every member of a group to apply commits (membership changes and key updates) in the same order. Something has to decide that order, and in Tieroute that's the group's hub: one server, named in the group's tieroute_group extension, that numbers the commits for that group and nothing else.

Hub and homeserver are separate roles that can fall on the same server.

  • A homeserver is per user: it holds your inbox and delivers your messages.
  • A hub is per group: it decides which commit wins each epoch.
  • When a group is created, its hub is the creator's homeserver. So for the creator, homeserver and hub are the same server. For every member on another server, the hub is someone else's homeserver.
  • A server is usually both at once: homeserver for its own users, and hub for the groups they created.

For example, Kat on a.example creates a group with Ravi on b.example. a.example is Kat's homeserver and the group's hub; b.example is only Ravi's homeserver. When Ravi's device commits, it sends the commit to b.example, which asks a.example to sequence it, then delivers it to everyone. When Kat's device commits, a.example sequences it locally and delivers it. Application messages never touch the hub at all.

The hub only sequences commits. The committer's own homeserver delivers them. That keeps the sender check in §10.2 intact, and it keeps the hub simple.

  1. A device that wants to commit submits the commit to its own homeserver (POST send, mls = "commit", epoch = N), with to listing every member identity, like any other group message. Any Welcomes for new members go in the same submission, so they don't depend on the device staying online.
  2. The homeserver asks the hub to sequence it, sending the device's signed commit-request (see epoch keys below) and the encrypted commit bytes. If the hub is another server, it uses S2S commit. If the homeserver is the hub, it does this step locally.
  3. The hub accepts the first valid commit request it sees for epoch N. It durably stores the commit and the new epoch state, and only then replies with a signed commit receipt. It rejects any later commit for epoch N with 409 stale-epoch. The exception is the very request it accepted, byte for byte: its reply may have been lost, and without a receipt the homeserver would never deliver the commit or its Welcomes. So the hub keeps each accepted commit-request with the commit, and answers an identical one with the same receipt. A registration (epoch-key, below) that already took effect is answered the same way, as success.
commit-receipt = {            ; body of a `signed`, t = "commit-receipt", signed with a hub server key (kid)
  "hub":   server-name,
  "group": group-id,
  "epoch": uint,              ; the epoch the commit was made in (N)
  "hash":  hash,              ; SHA-256 of the commit's raw bytes (the delivery's decoded `data`)
  "ts":    timestamp,
}
  1. On acceptance, the committer's homeserver tells the device, fans the commit out to every member's server with normal deliver calls with the receipt attached (receipt in the delivery), and sends the Welcomes. The sender check holds, because the committer lives on the server that's delivering. The device then applies its own commit.
  2. On stale-epoch, the homeserver tells the device and doesn't deliver anything. The device waits for the winning commit, processes it, and retries if its change is still needed.

The hub keeps accepted commits for 30 days, the same as inbox retention (§10.4). If the committer's homeserver crashes right after acceptance, or a member's server misses the fan-out, members aren't stuck. A client that's missing an epoch (it holds N, but sees a commit or a message for something later) fetches the missing commits from the hub (client API GET groups/{group}/commits, which its homeserver serves with S2S commit/fetch). Commits are numbered by the epoch they were made in, so the first one it needs is commit N: it asks with after=N−1. If one of them moves the hub (§11.11), it fetches the rest from the new hub. Commits are encrypted PrivateMessages that every member's homeserver sees anyway, so the hub learns nothing new.

The hub's state is (group-id → current epoch, current epoch public key, recent commits and receipts). It never sees membership or content.

Hub state MUST be durable. It's written before a receipt is issued, and never rolled back. A hub restored from an old backup holds stale epoch keys and will refuse every commit for its groups. The recovery is one-click re-create (§11.11). Restoring a server from backup breaks the groups it hosts, and servers SHOULD warn about this on restore. A server with no restore command has nothing to warn about; one that restores silently leaves operators to learn about frozen groups from their users.

Clients MUST verify the receipt on every commit before applying it: the signature has to come from the hub named in tieroute_group, and group, epoch, and hash have to match the commit. A commit without a valid receipt is dropped. That way a server other than the hub can't slip different commits to different members. The hub itself can: it can sign receipts for two different commits in one epoch and send each to different members. It can't do that without leaving proof, since each receipt is signed. A client SHOULD remember the hash of the commit it applied for each recent epoch, and when another commit with a valid receipt arrives for one of those epochs, stop following the group and offer re-create (§11.11), with a warning that the hub sent members different changes. A client that skips this can't tell a lying hub from a working one.

Commits for different epochs can come from different servers and arrive out of order. Clients buffer them and apply them in epoch order.

Application messages don't go through the hub. They fan out from the sender's homeserver directly. Clients buffer messages for an epoch they haven't reached yet.

If the hub is down, members can keep chatting in the current epoch, but nobody can change membership or rotate keys until it's back.

Epoch keys keep outsiders from freezing a group. Without them, the hub accepts the first commit for an epoch and can't tell a real commit from junk, so anyone who knows the group ID could freeze the group: a buggy client, or a member's homeserver.

How epoch keys work. Every epoch, the members derive a signing key from the group's state with the standard MLS exporter. Every MLS library has the exporter, so servers still never touch MLS.

epoch_seed_N  = MLS-Exporter("tieroute hub", group_id, 32)    ; in epoch N
epoch_key_N   = Ed25519 key pair from epoch_seed_N
  • Only members in epoch N can derive epoch_key_N. It's useless for reading messages: exporter outputs are independent of the encryption keys.
  • The hub stores only the public half for the current epoch. Asking the hub to sequence a commit takes a signature with the private half:
commit-request = {            ; body of a `signed`, t = "commit-request", signed with epoch_key_N; "k" is its public key
  "group": group-id,
  "epoch": uint,              ; N
  "hash":  hash,              ; SHA-256 of the commit's raw bytes
  ( "next": b64-32 //         ; public half of epoch_key_(N+1), derived from the epoch this commit creates
    "reg":  b64-32 ),         ; or: the public half of a one-time Ed25519 key that will register it after acceptance
}
  • A request carries exactly one of next and reg; a hub rejects one with both or neither (400 bad-request).
  • The hub accepts a request only if k matches the public key it holds for epoch N. On acceptance it replaces that key with next, or, for a reg request, stores reg and awaits the epoch's key (below).
  • Why a signature rather than a shared secret: the request passes through the committer's homeserver, and through the hub, in the clear. Both see only public keys and a signature. Neither can change hash or next, or reuse the request for a different commit.
  • Registering a group: the creator's homeserver registers the new group with the hub before any message is sent (S2S group/register, or locally, with group and the public half of epoch_key_0). The hub refuses commits for groups it doesn't know, and refuses to register a group ID twice (409 already-exists; the creator retries with a new ID, §11.5). Group IDs are random, so nobody can register someone else's group first.
  • Removed members know epoch_key_N for the epoch they were removed in. next comes from the new epoch, though, so they can't do anything once the removal is accepted.
  • Getting next before the hub accepts. The committer needs epoch_key_(N+1) while its commit is still pending. Every MLS implementation computes the new epoch's key schedule to build a commit (its confirmation tag and Welcome depend on it, RFC 9420 §8), so the secret always exists; what varies is whether the library lets you export from it before merging.
    • OpenMLS 0.9.2: works. It can't export from a staged commit directly, but it can from a copy: merge the pending commit into a copy of the group's storage and export there.
    • MLS++ (mlspp): creating a commit returns the next epoch's state, so the exporter is available at once. Not yet checked against current code.
    • mls-rs: not checked yet; a cloned group with the pending commit applied is expected to work.
  • The reg fallback, for libraries that can't. The committer generates a fresh, one-time Ed25519 key pair and puts its public half in the request as reg instead of next. The request is signed with epoch_key_N, so nobody can swap reg. On acceptance the hub stores reg and awaits the epoch's key: it refuses every commit for epoch N+1 with 409 awaiting-key. The committer merges its commit, derives epoch_key_(N+1), and registers its public half (client API POST groups/epoch-key, which its homeserver forwards with S2S group/epoch-key when the hub is elsewhere) in a signed epoch-key:
epoch-key = {                 ; body of a `signed`, t = "epoch-key", signed with the `reg` key; "k" is its public key
  "group": group-id,
  "epoch": uint,              ; N+1, the epoch the accepted commit created
  "next":  b64-32,            ; public half of epoch_key_(N+1)
}
  • The hub takes it only if the group is awaiting at that epoch and k is the stored reg; then next becomes the epoch's key and commits resume. One registration per reg.
  • Why it's safe: registering with epoch_key_N would let a member removed by that very commit, who knows epoch_key_N, race in a key of its own. Only the committing device holds reg's private half; the homeservers relaying it can't forge or substitute it.
  • The cost is liveness. Between acceptance and registration nobody can commit. If the committer vanishes in that gap, the group is stuck until it's re-created (§11.11). Clients SHOULD use next whenever their library allows. Using reg when next is available works, but adds the liveness gap for no benefit.

What this doesn't stop: a member can still freeze the group, by committing junk with a valid signature or by sending a bad next. With at most 25 people, that's a social problem: re-create the group without them (§11.11).

11.7 Leaving

Anyone may Remove their own devices and leave. Leaving uses a standalone Remove proposal for your own leaves, which the next committer includes (§11.1). Admin rules for the last admin are in §11.8.

11.8 Admins and adding people

  • In group groups, the creator is the first admin.
  • Admins can make other admins. Any admin may grant or revoke admin for any member, with a commit that changes admins in tieroute_group. Only admins may change tieroute_group, and only admins may Remove other members.
  • Admins add people directly. Everyone else proposes, and an admin approves. An admin may Add any of their own contacts. A member who isn't an admin asks first, and the Add only happens once an admin has approved it.
  • A group never ends up without an admin. If the last admin leaves, the longest-standing member becomes admin automatically (below).
  • Clients MUST reject commits that break these rules.

How a proposal and approval work.

  1. The member sends add-request content (§12) naming the person, who MUST be one of the member's own contacts (their homeserver won't take the Welcome otherwise, §10.3). Every member's client shows it: "Kat wants to add @amara.example.com."
  2. An admin approves by sending add-answer content carrying a signed add approval, or declines by sending add-answer without one.
  3. The member who asked then commits the Add. In the same commit, its GroupContextExtensions update to tieroute_group (which the Add needs anyway, for since) sets approval to the signed approval and adds the request's msg-id to used. Proposals inside a commit are encrypted, so servers don't learn who was added or who approved; and every member processing the commit has the approval, whenever they joined. (MLS authenticated data would be visible to servers, RFC 9420 §6.3.1, so it stays empty.)
add-approval = {              ; body of a `signed`, t = "add-approval", signed by a current device of `by`
  "group":   group-id,
  "request": msg-id,          ; the add-request it answers
  "who":     identifier,      ; the person being added
  "by":      identifier,      ; the approving admin
  "epoch":   uint,            ; the group's epoch when it was issued
  "exp":     timestamp,       ; at most 7 days after it's issued
}
  • Every member reaches the same verdict from the commit alone. For an Add of a new identity by a non-admin, members check the approval the same commit sets: its signature verifies; its signer is a device of by as of the commit's hub receipt time (§7.1); exp is no more than 7 days and 1 hour after that time; by is in admins as of the epoch the commit is made in; group and who match the commit; epoch is no later than that epoch; exp is no earlier than the ts of the commit's hub receipt (§11.6), which every member sees the same way, unlike their own clocks; and request isn't already in used. The commit MUST add request to used. Otherwise the commit is rejected.
  • Clock margins. The committer can't know the hub receipt's ts when it builds the commit, and a commit the hub accepted but every member rejects freezes the group. So a committer MUST NOT use an approval whose exp is less than an hour away by its own clock, and MUST NOT drop a used entry until it's an hour past its timestamp by its own clock.
  • used is what makes an approval single use. Any commit that changes tieroute_group MAY drop entries from used whose timestamp is before its hub receipt's ts, since those approvals have expired anyway; dropping any other entry is rejected. Any later change to tieroute_group MAY also clear approval.
  • A person who was added, removed, and is being added again needs a new request and a new approval: the old one is in used.
  • Why the requester commits, not the admin: the new person's homeserver accepts a Welcome only from one of their contacts (§10.3). The member who asked is their contact; the admin may not be.
  • Adding another device for an identity that's already a member needs no approval (§11.9).

How "longest-standing" is decided. It has to be something every client computes the same way:

  • Every commit that adds a new identity MUST also update since in tieroute_group, recording the epoch the commit creates (N + 1 for a commit made in epoch N). The update is a GroupContextExtensions proposal in the same commit. A non-admin may change tieroute_group only alongside an approved Add, and only as the next bullet allows. Adding another device for an identity that's already a member doesn't change since. Clients MUST reject an Add of a new identity that doesn't update since.
  • Removed identities leave the extension. A commit that removes an identity's last leaf MUST, in the same commit, drop it from since and from admins. Clients reject a commit that leaves either listing a non-member. A non-admin's GroupContextExtensions change may only add the new identity to since, set approval, add to used, drop expired used entries, clear approval, and make the changes the rules here require of every commit: dropping removed identities from since and admins, and picking an admin when none is left (below). Every other field stays the same. A non-admin can commit someone's leave proposal, so it needs those changes too.
  • The last admin leaving: their client MUST first commit an update setting admins to the remaining member with the smallest since (ties go to the bytewise-smallest identifier), then send its leave.
  • The last admin disappearing (identity gone, all devices removed): if a commit leaves no admins in the group, that same commit MUST also set admins to the member picked by the rule above. Any member may make that commit. Clients reject any other change to admins by a non-admin.

11.9 Keeping devices in sync

When an identity's chain gains or loses a device, every group it's in has to follow:

  • Any member, right away. Whoever notices the change first, the user's own devices or any other member, commits it. The hub's first-commit-wins rule drops duplicates (409 stale-epoch); the loser processes the winning commit and checks again.
  • Removed devices first. Until the Remove lands, the removed device can still decrypt new messages, so Removes are committed before Adds.
  • An identity's last leaf goes with its replacement. Removing every leaf an identity has in a group takes the identity out: off since and admins, and back in only as a new person, with an approval (§11.8). So when the identity's chain has a device that isn't in the group yet, the Remove of its last leaf MUST go in the same commit as the Add of at least one of those devices. If none of them has a key package yet, what happens depends on why the device went. A self-rotation (§6.4.1: the device replacing its own key) can wait, for up to an hour, for a key package for the new key, then the Remove goes alone: the old key belongs to the same device, which has given it up. Any other removal (by another device, or by the recovery code after a theft) goes at once, alone if need be, even though that takes the identity out of the group: until the leaf is gone, the removed device can still read. Replacing a leaf in one commit leaves since and admins as they were.
  • A member who isn't an admin may Remove another identity's leaf only when that device is revoked as of the commit's hub receipt time (§7.1).
  • Added devices: claim key packages for only the missing devices and commit Adds through the hub, with a Welcome to the identity.
  • A device MUST NOT commit for its own identity once its own key is no longer in the chain.
  • System lines. Clients SHOULD show a line in every chat whose members' devices changed ("@kat has a new device"), so everyone in the chat can see a device that was slipped into someone's identity. A client with no chat view, such as a bot, can skip it; elsewhere, without it, an unwanted device goes unnoticed.

A device that's been offline too long. Inbox deliveries and hub commits are kept for 30 days. A laptop left in a drawer for five weeks can't catch up on its groups. The protocol handles this with machinery it already has:

  1. The device finds out it's behind. On login, its homeserver reports "gap": true on the inbox when deliveries expired before this device acked them, and the hub's commit/fetch answers 410 expired for epochs it no longer holds.
  2. It retires its old key and comes back as a new device. It appends a single self-rotation entry, remove-device for its old key plus add-device for a fresh one, signed by the old key (§6.4). Other devices get a calm notice, not the thief alarm (§6.4.1).
    • If the user already removed this device (for example, they reported the laptop lost), its old key can't sign anything anymore. It has to come back like any new device: QR linking from another device (§7.2), or the recovery code.
  3. The rules above do the rest. The user's other devices, or other members if there are none, remove the stale leaf and add the new one in every group.
  4. History comes back from the encrypted backup (§7.5), or from another of the user's devices (§7.2). Anything in between that neither has is gone from this device.

The client SHOULD tell the user: "This device was offline too long to catch up. It's been re-added to your chats." A client that can't show messages, such as a background service, can skip it; the user then sees a gap in their history with no explanation.

11.10 Size cap

A group chat has at most 25 people (identities), which keeps groups to a circle that knows each other.

A group also has at most 250 leaves, which allows each person up to 10 devices. Clients MUST reject commits that would go over either limit.

11.11 Hub handoff and re-creating a group

An admin can move the hub by committing a GroupContextExtensions proposal with a new hub. This commit is sequenced by the old hub. If the old hub is gone permanently, the group can't change membership.

Registering with the new hub. The admin's homeserver registers the group with the new hub (S2S group/register) at the epoch the handoff commit creates, with that epoch's key. The new hub can't read the commit, so it can't check that the caller speaks for the group. Two rules keep the damage small:

  • A hub SHOULD accept group/register only from a server that's home to at least one full contact of its own users, and answer 403 not-allowed otherwise. A hub that federates only with servers it already knows can skip this; one that accepts registrations from anyone lets any server that has seen a group ID block that group's handoff.
  • If the registration fails anyway (409 already-exists, because another server registered the ID first, or 403), the admin's client doesn't commit the handoff, and offers one-click re-create instead (below). A server that registers a group first gains nothing: the hub never sees content, and the real members never commit through it. When no remaining member lives on the hub's server (its creator left or moved), an admin's client SHOULD move the hub to its own homeserver. An admin can leave it if the hub's server is reliable and willing; otherwise the group depends on a server none of its members use, and can't change membership if that server goes away.

One-click re-create is the recovery path for a group that's stuck, whether the hub is gone for good or a bad commit froze it. Any member can do it. Their client creates a new group with the same members, name, topic, and avatar, with their own homeserver as hub. Local history carries over on each device, and the new group is linked to the old one so clients can show them as one conversation. Clients SHOULD offer it when the hub has been unreachable for 24 hours, or when every commit attempt has failed for an hour. A client MAY wait longer, at the cost of a stuck group staying stuck; offering it sooner risks splitting a group over a brief outage.

The re-creator is the new group's first admin, so they add their own contacts directly. Members who aren't their contacts are added the usual way: an Add by one of their contacts, approved by the re-creator (§10.3, §11.8). Once everyone is back, the re-creator SHOULD grant admin to the old group's admins. The re-creator can skip this if the old admins left or agreed; otherwise the re-creator has quietly taken the group over.

Linking the two. The new group's tieroute_group extension carries replaces: the old group's ID. Every member's client sees it when it joins, from the Welcome. Nothing has to be sent in the old group, so this works even when its hub is gone. A direct chat re-created between the same two people is always the same conversation, whether or not replaces is set.

replaces is a claim, not proof. Group IDs aren't secret, and anyone who can send you a Welcome can name any group in it. So for a group chat:

  • A client MUST NOT link a new group to an old one unless whoever sent the Welcome, every admin of the new group, and everyone in it when the client joins were members of the old group, as the client last saw it. Otherwise the new group is a separate chat.
  • Even then, it's an offer. Until the user switches, the new group is shown as its own chat, and the old chat keeps sending to the old group. Before the user switches, the client SHOULD show who from the old group isn't in the new one, and whether its admins differ. A client without a chat view, such as a bot, can skip this; it then never switches on its own.
  • A client MUST NOT stop receiving the old group (POST groups/unregister) until the user switches.
  • A user's own re-creation, arriving at their other devices, links at once.

12. Message content

The plaintext inside an MLS application message is an app-message: a signed object (§4.3) with t = "app-message", signed by the sender's device key, carried as JCS(signed). Servers never see it. It's specified here so different clients can talk to each other.

The app-message envelope, the checks below, and text are part of the core. The other content types are drafts.

The signature lets a recipient prove what was sent when they report it (§16.2). Tieroute adds its own signature instead of relying on MLS's internal one, because every MLS library exposes the decrypted bytes, but not every library exposes MLS signatures. One extra Ed25519 signature per message costs almost nothing.

app-message = {             ; body of a `signed`, t = "app-message", signed by the sender's device key
  "id":    identifier,      ; sender; MUST match the MLS sender's credential
  "group": group-id,        ; MUST match the group it arrived in
  "ts":    timestamp,
  "c":     content,
}

content = text / edit / delete / react / file / receipt / typing / group-meta / call
        / verify-commit / verify-reveal / add-request / add-answer / unknown-content

text = {
  "t": "text", "id": msg-id, "ts": timestamp,
  "body": tstr,                   ; Markdown subset (§12.1)
  ? "reply": msg-id,
  ? "mentions": [+ identifier],   ; people @-mentioned in body (§12)
  ? "receipt": true,              ; the sender asks for a read receipt (§13.3)
  ? "previews": [* preview],
  ? "files": [* media-ref],
}
edit       = { "t": "edit",    "id": msg-id, "ts": timestamp, "target": msg-id, "body": tstr }
delete     = { "t": "delete",  "id": msg-id, "ts": timestamp, "target": msg-id }
react      = { "t": "react",   "id": msg-id, "ts": timestamp, "target": msg-id, "emoji": tstr, ? "remove": bool }
file       = { "t": "file",    "id": msg-id, "ts": timestamp, "ref": media-ref }
receipt    = { "t": "receipt", "ts": timestamp, "upto": msg-id }
typing     = { "t": "typing",  "ts": timestamp, "on": bool }
group-meta = { "t": "group-meta", "ts": timestamp, ? "name": tstr, ? "topic": tstr, ? "avatar": group-picture / null }
group-picture = {                 ; carried inline, never a media-ref (§14 files expire)
  "mime": "image/png" / "image/jpeg" / "image/webp" / "image/gif",
  "data": b64,                    ; the picture, at most 256 KiB (262,144 bytes)
}
verify-commit = { "t": "verify-commit", "ts": timestamp, "round": b64-16, "c": hash }      ; §6.7
verify-reveal = { "t": "verify-reveal", "ts": timestamp, "round": b64-16, "r": b64-32 }   ; §6.7
add-request = { "t": "add-request", "id": msg-id, "ts": timestamp, "who": identifier }    ; §11.8
add-answer  = { "t": "add-answer", "ts": timestamp, "target": msg-id, ? "approval": signed }   ; §11.8; no approval = declined
unknown-content = { "t": tstr, * tstr => any }   ; only for t values the client doesn't know; a malformed known t is an error

preview = {                       ; built by the sender's client (§2.1: no fan-out stampedes)
  "url": tstr, ? "title": tstr, ? "desc": tstr, ? "image": media-ref,
}

Clients MUST verify the signature, check that the signer is a current device of id, and check that id and group match the MLS sender and group. A message that fails these checks is dropped.

  • Only the author edits or deletes. An edit or delete MUST come from the same identity as the message it targets; others are dropped. A react may come from anyone in the chat.
  • Two ids: in the app-message envelope, id is the sender's identifier; inside content, id is the message's own msg-id.
  • Clients MUST show an "unsupported message" placeholder for unknown t values instead of dropping them.
  • A known t that doesn't match its schema is an error, not unknown content. For example, a text without body MUST be dropped as malformed (and MAY be logged), never shown as "unsupported." unknown-content only matches t values the client doesn't know.
  • Clients MUST NOT fetch a URL to build a preview for a message they received. Only the sender fetches.
  • Group pictures. A group-meta carries the group's whole state: a member who sends one includes the current name, topic, and picture, so members who joined later get them all. In avatar, null removes the picture, and leaving the key out keeps the current one. The picture travels inside the encrypted message, not as a file, because files expire after 30 days (§14) and a group outlives that. It's at most 256 KiB so that a group-meta still fits in one delivery after base64url twice (once here, once around the MLS ciphertext; conventions: deliveries are at most 1 MiB). Clients SHOULD shrink a larger picture before sending it. A group-meta whose picture is another type or too large is malformed, and is dropped whole.
  • Mentions. text may carry mentions, the identifiers of people it @-mentions, while body keeps the readable @handle. Clients highlight mentions and MAY notify a mentioned person even in a muted chat.
  • Why mentions are a separate field. The @handle in body is for people to read, and it isn't reliable for software. Handles change (§5.3), so an old message's @kat.chat.example may later point at someone else or nobody, and finding mentions in text means every client parsing it the same way. mentions names the identities exactly, so highlighting and notifications work without parsing body and still work after a rename. body keeps the @handle so clients that ignore mentions still show something readable.

12.1 Text formatting

Message text is a Markdown subset:

Syntax Result
**bold** bold
*italic* or _italic_ italic. Underscores count only at word boundaries: snake_case_name stays literal.
~~strike~~ strike
`code` inline code
``` blocks code block, no syntax highlighting required
> quote quote, one level
bare https:// URLs links, auto-detected

Everything else is shown as literal text: headings, images, tables, raw HTML, and [text](url) links. Leaving out [text](url) is on purpose: link text can hide where a link really goes, so every link shows its actual URL. A backslash escapes a formatting character. Clients that don't render formatting show the raw text, which is still readable.

Tieroute defines an exact grammar for this subset, with formatting test vectors (spec/vectors/formatting.json), so every client renders a message the same way.

The grammar. Parsing is two passes: blocks by line, then inline spans within each line. Inline spans never cross a line break.

Blocks

Split body on \n (a \r before it is dropped).

  1. Code block: a line that starts with ``` opens one; anything after the backticks on that line is ignored. It closes at the next line that is exactly ``` (trailing spaces allowed). The lines between are literal text, with no inline parsing. An unclosed block runs to the end.
  2. Quote: a line that starts with > is a quote line; one space after the > is dropped. Consecutive quote lines form one quote. Its lines are parsed inline, so a > inside a quote is literal text (one level only).
  3. Everything else is a paragraph line. Consecutive paragraph lines form one paragraph, with a line break between each. Empty lines end a paragraph and are kept as blank lines.

Inline spans

Inline spans are parsed scanning left to right:

  1. Escape: \ followed by one of \ * _ ~ `` `` >produces that character literally. Any other` is literal.
  2. Code span: ` up to the next ` on the line; the inside is literal. An unmatched ` is literal.
  3. Link: https:// followed by one or more non-space characters, minus trailing . , : ; ! ? ' " and any ) that has no matching ( inside the URL. Only https:// counts.
  4. Emphasis: at a delimiter, try ** (bold), then ~~ (strike), then * or _ (italic). An opener has to be followed by a non-space; its closer is the nearest later same delimiter on the line that's preceded by a non-space. _ also needs a word boundary: an opening _ can't follow a letter or digit, and a closing _ can't be followed by one, so snake_case_name stays literal. ** and ~~ always act as a pair: if they can't open a span, both characters are literal, and a closer search for * skips over **. Escapes and code spans are skipped while looking for a closer. Spans nest, but not inside a span of the same kind. A delimiter with no closer is literal text.

An empty body has no blocks.

The result is a tree of paragraphs, quotes, and code blocks containing text, line breaks, bold, italic, strike, code, and links. The vectors give the expected tree for each case.

  • The content format of the IETF (Internet Engineering Task Force) MIMI working group (More Instant Messaging Interoperability) isn't adopted for v1: it's still a draft and much larger. A mapping to it may be considered once it's an RFC.

13. Presence and profiles

Tieroute doesn't encrypt everything. Messages, files, and group details are end-to-end encrypted. Presence and public profiles are not: they're meant to be seen, and servers can see them too.

13.1 Public profiles

Anyone can see your display name, profile picture, and bio, including people who haven't added you. That's how you know who's sending you a contact request.

A profile is signed by one of your devices, so servers can relay it but can't alter it:

profile = {                   ; body of a `signed`, t = "profile", signed by a current device of `id`
  "id":       identifier,
  "v":        uint,           ; version; goes up by one on every change
  "ts":       timestamp,
  ? "name":   tstr,           ; display name, at most 64 characters
  ? "bio":    tstr,           ; at most 500 characters
  ? "links":  [* tstr],       ; https URLs, at most 5
  ? "avatar": public-media,
  ? "invite": tstr,           ; a public contact invite code (§9.1)
}

public-media = {              ; unencrypted media, for profiles only
  "server": server-name,
  "id":     b64-16,
  "sha256": hash,             ; of the file
  "size":   uint,
  "mime":   "image/png" / "image/jpeg" / "image/webp" / "image/gif",
}
  • You publish with POST profile and POST media/upload-public (client API). The homeserver serves profiles to anyone with S2S profile/fetch, and clients get them through their own homeserver (GET profile/{id}).
  • Profile pictures are at most 1 MB. Animated GIFs are allowed.
  • Contacts find out about changes through presence: each presence update carries your profile version (pv), and when it goes up they fetch the new profile.
  • Clients MUST verify the signature and that the signer is a current device of id.
  • Links (your site, your Mastodon, anything) are plain https URLs shown as-is. Nothing is verified, and clients MUST show the full domain of each link so it can't impersonate another site.
  • A public invite (invite) lets anyone who sees the profile send a contact request (§9.1). Leave it out to be reachable only by people you've given a code.

13.2 Presence

  • Status messages and auto-away are core features.
  • Presence (your state and status message) is visible to homeservers.
  • Presence goes only to mutual contacts, and is batched per destination server.
presence-state = "available" / "online" / "away" / "busy" / "offline"

presence-update = {           ; S2S body for path "presence"
  "updates": [+ {
    "id":       identifier,   ; whose presence changed
    "state":    presence-state,
    ? "status": tstr,         ; status message, at most 128 characters
    "pv":       uint,         ; current profile version (§13.1)
    "ts":       timestamp,
    "to":       [+ identifier],   ; mutual contacts on the destination server
  }],
}
State Meaning
available Actively welcoming messages: "message me, I'll happily reply." Set by the user, never automatically.
online Connected, with no particular signal either way. The default while the app is in use.
away Connected but idle. Set automatically by the client after inactivity, or by the user.
busy Connected but doesn't want to be disturbed. Clients MAY hold notifications.
offline Not connected, or appearing offline.
  • The status message ("listening to…", "brb") goes only to contacts. Strangers see your profile, but not your status.
  • The client sets its state with POST presence. Auto-away is decided by the client; it moves available and online to away, and back to whichever the user had.
  • "Appear offline" means the client sends offline and the homeserver stops sharing presence. The homeserver still knows the truth.
  • Homeservers SHOULD coalesce updates and send at most one presence call per destination server every 5 seconds. A server with few users can send each update right away; at scale, sending each change separately multiplies S2S traffic.

13.3 Typing indicators and read receipts

  • They're typing and receipt content (§12), so they're end-to-end encrypted like any other message. That isn't expensive: they ride the chat's existing encryption, and each one costs about the same as a one-word message.
  • They're sent as ephemeral deliveries ("ephemeral": true, §10.1). The homeserver passes them only to devices that are connected right now, never stores them in the inbox, and never retries them. An offline user doesn't come back to a pile of old "typing" notices.

Typing. "Kat is typing…" is on by default. A client sends typing on at most once a second while the user types, and typing off after 7 seconds without a keystroke, or when the message is sent. Receivers SHOULD hide the indicator if they hear nothing for 10 seconds. A client can use another timeout, but without one, a lost typing off leaves the indicator on.

Read receipts need both sides to agree. The sender asks, and the reader decides:

  • The sender asks by setting receipt: true on a text (§12). Clients let the user turn this on or off; it's off by default.
  • The reader decides. A client MUST NOT send a receipt for a message that didn't ask for one, and MUST NOT send one the user hasn't agreed to. The first time someone asks in a chat, the client asks the reader once, "Send read receipts to Kat?", and remembers the answer for that chat; the user can change it later. Declining is silent: the sender never gets a receipt.
  • A receipt acknowledges every message up to upto that asked for one.

14. Files

  • Tieroute is a chat protocol, not a file sharing service. Files are at most 64 MiB (67,108,864 bytes) and are kept for 30 days. Servers MUST accept files up to 64 MiB, MUST NOT accept larger ones, and SHOULD delete files 30 days after upload. A server may keep a file longer, but clients treat files older than 30 days as expired, so the extra storage serves nobody and keeps ciphertext around longer than users expect.
  • A file is encrypted on the client with a random key and uploaded to the sender's homeserver (POST media/upload, raw bytes as application/octet-stream), which returns a media ID.
  • The key and hash travel inside the encrypted message:
media-ref = {
  "server": server-name,
  "id":     b64-16,            ; random, unguessable
  "key":    b64-16,            ; AES-128-GCM key
  "sha256": hash,              ; of the ciphertext
  "size":   uint,              ; plaintext bytes, at most 64 MiB
  ? "name": tstr,
  ? "mime": tstr,
}
  • Each file has its own key and is encrypted once, so a fixed all-zero 12-byte nonce is safe.
  • Recipients fetch through their own homeserver (GET media/{server}/{id} on the client API), which fetches with S2S media/fetch. The sender's server never sees the recipient's IP address.
  • A file that has expired returns 410 expired, and clients show "this file has expired". A file removed after a report returns 410 removed (§16.2).
  • Servers MAY set a per-user storage quota. An upload over quota gets 413 over-quota.
  • There's no peer-to-peer file transfer. Every file goes through the sender's homeserver.

15. Calls

15.1 NAT traversal

  • Each homeserver runs a relay for its own users: TURN (Traversal Using Relays around NAT, where NAT is network address translation). GET turn on the client API returns time-limited TURN REST credentials. Relay costs land on the homeserver that chose to host those users.

15.2 Calls between contacts

You can call a contact directly, with no other server involved. Calls work in direct chats and in group chats.

At most 10 people in one call. A group can have 25 people (§11.10), but calls are a full mesh (below), and each person's upload grows with every other participant. At 10, each person sends nine audio streams, about 450 kbps. When a call is full, clients don't offer to join it.

How a call works:

  • Signaling goes through the chat. Call setup (ringing, the WebRTC (Web Real-Time Communication) offer and answer, ICE (Interactive Connectivity Establishment) candidates, hanging up) and each participant's mute state are sent as call content (§12) inside the chat's MLS group, so it's end-to-end encrypted like any other message.
  • Media is WebRTC between the participants. There's no media server. Group calls are a mesh: each participant connects to every other participant.
  • Two layers of encryption. WebRTC's DTLS-SRTP (Datagram TLS, keying the Secure Real-time Transport Protocol) encrypts media between peers, and its fingerprints travel inside the SDP (Session Description Protocol) offer, which travels inside MLS, so no server in the middle can swap them. But DTLS key exchange is elliptic-curve, so on its own it's exposed to harvest-now-decrypt-later. Call media is therefore also encrypted with SFrame, keyed from the chat's MLS group (RFC 9605 §5.2, the MLS-based key schedule). The MLS group uses the hybrid post-quantum suite (§3.1), so calls inherit its post-quantum protection.
    • The SFrame keys come from the MLS exporter for the group's current epoch, as in RFC 9605 §5.2, with Tieroute's own exporter label and context. Every member of the chat could derive them, so a call is exactly as private as the chat it's in. Members who aren't on the call never receive the media anyway. Exactly:
      • The cipher suite is AES_128_GCM_SHA256_128 (0x0004).
      • base_key = MLS-Exporter("SFrame 1.0 Tieroute call", call, 16), with the 16-byte call ID as the context. Each call, and each epoch, has its own base_key.
      • The key ID is KID = (sender_index << 4) + (epoch % 16): in RFC 9605's terms, E = 4 bits of epoch, S = 8 bits for the sender's MLS leaf index (a group has at most 250 leaves, §11.10, so every index fits), and context 0.
      • sframe_key and sframe_salt come from base_key and the KID as in RFC 9605 §4.4.2. The metadata is empty.
      • Each SFrame ciphertext carries one encoded audio frame, encrypted before RTP (Real-time Transport Protocol) packetization. A sender starts CTR (the frame counter) at 0 for each KID and adds 1 for every frame, and MUST NOT encrypt two frames with the same KID and CTR (RFC 9605 §4.3).
      • When the epoch changes during a call, a sender switches to the new epoch's key as soon as it has applied the commit. A receiver keeps the previous epoch's keys for 10 seconds after applying the commit, so frames already in flight still decrypt, and then deletes them. It MUST delete an old epoch's keys as soon as a newer epoch with the same low 4 bits is in use (RFC 9605 §5.2). A frame whose KID has no key is dropped.
      • Vectors: spec/vectors/sframe.json, which also carries RFC 9605's own vectors for this suite.
    • Implementation note: SFrame in a webview needs encoded transforms (RTCRtpScriptTransform), and support differs between WebKit, WebView2, and WebKitGTK. It also puts the SFrame keys in the page's JavaScript. Running WebRTC natively, outside the webview, avoids both issues.
  • Relayed by default, to hide IP addresses. A direct peer-to-peer connection shows each person's IP address to the others. Clients therefore use relay-only ICE by default: media goes through each side's own homeserver TURN (§15.1), and neither side sees the other's address. A setting can allow direct connections for better quality. Audio is cheap to relay (about 50 kbps per stream).
  • Ringing: ring is a normal (non-ephemeral) delivery, so a device that's offline sees "missed call from Kat" later. Everything else in a call is ephemeral (§13.3). All the callee's devices ring. The first one to accept takes the call, and the user's other devices see the accept and stop ringing.
call = {
  "t":    "call",
  "ts":   timestamp,
  "call": b64-16,            ; random call ID
  "op":   "ring" / "accept" / "decline" / "sdp" / "ice" / "hangup" / "state",
  ? "to":   identifier,      ; for "sdp" and "ice": the identity of the device it's for
  ? "dev":  device-key,      ; for "sdp" and "ice": the device it's for
  ? "data": tstr,            ; for "sdp" and "ice": JSON text, as above
  ? "media": [+ ("audio" / "video" / "screen")],   ; on "ring"; v1 sends ["audio"] only
  ? "muted": bool,           ; on "state": whether the sender's microphone is muted
}
  • Participants are devices. Each device in a call is its own peer, with its own connection to every other device in the call. The device that sent a call message is the MLS sender of the message that carried it. In sdp and ice, to and dev name the one device it's for, and every other device ignores it.
  • data for sdp is the JSON text of { "type": "offer" / "answer", "sdp": tstr }, and for ice, of { "candidate": tstr, ? "sdpMid": tstr, ? "sdpMLineIndex": uint }, the shapes WebRTC itself uses. An ice with an empty candidate means the sender has no more candidates.
  • Who offers. In a direct chat, the caller's device sends its offer to the first device that answers with accept, and only to that one. The callee's other devices see the offer go to a different device of theirs, stop ringing, and show "answered on another device". In a group call, every device already in the call sends an offer to each device whose accept arrives, so the device joining never needs to know who's in the call.
  • Crossing offers. When two devices accept a group call at about the same time, each can see the other's accept after joining, and both send an offer. A device that receives an offer from a device it has itself sent an unanswered offer to keeps its own offer if its device key is lower, comparing the 32 bytes in order, and ignores the one it received. Otherwise it drops its own offer and answers the one it received. Both devices compare the same two keys, so exactly one connection results, with nothing more exchanged.
  • Ringing lasts 60 seconds. If nobody answers, the caller's device sends hangup, and it's a missed call. In a direct chat, a decline from any of the callee's devices stops all of them ringing, and the caller sees the call declined. In a group, decline only means that person isn't joining; the call goes on for whoever is in it. A call ends for a device when it sends hangup, and for everyone when the last other participant hangs up.
  • One call at a time. A device is in at most one call. A ring that arrives while it's in another call is shown as call waiting, with the choice to decline it or to end the current call and answer. Nothing is sent back on its own: replying "busy" would tell the caller you're on a call, and would be wrong when another of your devices is free to answer. Unanswered, it becomes a missed call when the caller's ringing ends.
  • One call per chat. If a device sees two calls in the same chat, as when two people call each other at the same moment, everyone converges on the call whose call ID is lower, comparing the 16 bytes in order. In a direct chat, the device whose own outgoing call has the higher ID drops it and accepts the other, so the call connects with no prompt. In a group, devices in the higher call hang up and join the lower one. Every device compares the same two IDs, so they all agree without exchanging anything.
  • Mute state. A device in a call sends state with muted whenever its microphone is muted or unmuted. While it's muted, it sends state again each time another device joins the call, because a device that joins assumes everyone in it is unmuted. The others show who's muted and MAY play a sound when it changes. A device that isn't in the call ignores state. muted is only the sender's own claim, for display: what a device sends is what everyone hears, and nothing relies on the flag. Later versions can add fields to state, such as whether video is on.
  • Voice only for now. Video calls and screen sharing may come later, and the protocol leaves room for them.
  • Room for video and screen sharing: ring lists the media kinds being offered (media). In v1 it's always ["audio"]. A client that receives an offer containing a kind it doesn't support MUST still answer with the kinds it does support, rather than fail. Relaying video costs about 1 to 2 Mbps per stream, so relay-only by default will need a rethink when video arrives.

16. Moderation

16.1 What servers can and can't do

Report-only moderation. Clients never scan messages, before or after encryption, and nobody can scan direct chats, group chats, or their files. Encrypted content is checked only when a user reports it.

Servers can't scan direct chats or group chats: they're end-to-end encrypted. (That also means server-side upload hash matching, common on public image hosts, can't run on them.) The tools are:

  • Invite-only contact requests and rate limits (§9). This is the main spam defense.
  • Blocking (§9.6).
  • Reports, with proof the sender really sent the message (§16.2), and scanning of reported attachments only (§16.2).
  • Defederation (§16.4).

16.2 Reporting a message

To report a message, the user opts in to sharing a decrypted copy with an admin, along with proof that it's genuine. The admin can check that the sender really sent it, and that it matches a message the server actually carried.

How the proof works. Every chat message is already signed by the sender's device (§12), inside the encryption. A decrypted copy therefore carries its own signature:

  1. The user picks the messages to report. The client shows exactly what will be shared, and nothing else goes.
  2. For each message, the report includes:
    • the signed app-message exactly as decrypted
    • the delivery ID
    • the SHA-256 of the encrypted bytes it arrived in
  3. The admin verifies the signature against the sender's identity chain: the signer has to be a device of the reported identity at the time of the message. That proves the reported person sent exactly this text, in this group. The ts inside is the sender's own claim; the server's arrival time (step 4) is the independent one. Nobody else, not the reporter or any server, can produce that signature.
  4. The delivery ID and hash link the copy to the encrypted message the server handled. Homeservers MAY keep (delivery ID, SHA-256 of the data, from, arrival time) for 30 days so they can confirm the link. The signature is the actual proof; the link is extra.

The trade-off: signatures make messages provable to third parties. Tieroute doesn't offer deniability. MLS signs everything already, so it wouldn't have it anyway.

Reported attachments: scanning on report. A reported message that carries a file includes its media-ref (§14), and a media-ref contains the file's key. So when an attachment is reported, an admin can check it:

  1. Fetch the encrypted file from the sender's homeserver (S2S media/fetch, or the admin's own client).
  2. Check that its SHA-256 matches the sha256 in the signed media-ref. That proves this is the exact file the sender sent: the reporter can't swap in a different file or key, because both are covered by the sender's signature.
  3. Decrypt that one file with the key from the report.
  4. Check it against known-image hash lists, the way public image hosts check uploads.

Nothing is scanned unless a user reports it, and a report decrypts only the files the reporter chose to share, never anything else on the server.

  • Deleting a reported file. The sender's homeserver MAY delete a reported file, whether its own admin confirmed the report, or a forwarded report (S2S report) convinced it. After deletion, fetches return 410 removed (distinct from 410 expired), and clients show "this file was removed."
  • What an operator does with a confirmed report, including legal obligations, is operator guidance, not protocol.

16.3 Reports

report = {
  "reporter": identifier,
  "reported": identifier,
  "reason":   tstr,
  ? "evidence": [* {
      "msg":      signed,        ; the reported app-message, as decrypted (§12)
      ? "delivery": msg-id,      ; the delivery it arrived in
      ? "blob":     hash,        ; SHA-256 of that delivery's decoded `data`
  }],
}

A report goes to the reporter's homeserver. The reporter can choose to also forward it to the reported user's homeserver. Each admin decides what to do with their own users.

16.4 Defederation

  • A server admin can refuse all S2S traffic from specific servers.
  • When that cuts a user off from one of their contacts, the user's homeserver MUST notify them ("Your server has stopped talking to example.com; you can't reach Kat anymore"). Messages never disappear without notice.
  • No shared blocklists. There's no mechanism for subscribing to blocklists published by other admins. Each admin decides on their own.

17. Security considerations

The wire format MUST NOT be called stable until this section has had outside review (§17.4) and its open items are resolved.

17.1 What Tieroute protects

  • Content of direct chats and group chats. End-to-end encrypted with MLS (§11). Only members' devices can read it.
  • Calls between contacts. DTLS-SRTP plus SFrame keyed from the chat's MLS group (§15.2).
  • Identity. Self-certifying (§6). No server can forge your chain, take over your identifier, or stop you from moving.
  • Authorship. Chat messages, profiles, and chain entries are all signed by device keys, so nobody can put words in your mouth. (The flip side is that there's no deniability, §16.2.)
  • Reachability. Only people you've given an invite code, or who read the one on your profile, can ask to be your contact (§9.1).

What it deliberately doesn't protect (§18.1): who talks to whom (metadata), presence, and public profiles.

17.2 Attackers

Attacker Can Can't
Your own homeserver, malicious or compromised Drop, delay, or reorder your deliveries. See your contacts, groups, presence, and who you talk to and when (§18.1). Refuse to serve your chain or append to it. Lock you out of your identity until you move. Make offline guesses at your backup passphrase (§7.5). Withhold presence or lie about it. Point a handle you're adding for the first time at a different identity, since you resolve handles through it (§5.3); verifying the contact (§6.7) catches it. Read direct chats, group chats, files, or backups. Forge messages, chain entries, or profiles. Keep you from moving: contacts already cache your chain, and a recovery-signed set-home works without the old server (§6.6).
A contact's homeserver Drop or delay what you send them. See that you're talking to its user. Rate-limit or block your server. Read content. Impersonate you to its user (your chain and messages are signed).
A group's hub Stall membership changes and key rotation for that group. Refuse service, which forces a re-create (§11.11). Give different members different commits for the same epoch, leaving signed receipts that prove it (§11.6). Read messages. Add members. Accept junk commits from outsiders (epoch keys, §11.6). Give different members different commits without a member who sees both noticing (§11.6).
A thief with one of your devices Everything that device can do: read and send in your chats, add devices, move your homeserver, remove your other devices. Rotate your recovery code. Make changes stick if you act within 72 hours: the recovery code overrides them, and your other devices are alerted at once (§6.4.1). Move your identity without your homeserver seeing it: device-signed entries count only once the home before them appended them (§6.5).
A thief with one of your devices, working with your homeserver Everything above, plus backdating device-signed entries past the 72-hour window: only the appending homeserver checks that an entry isn't dated in the past (§6.4), and vouching (§6.5) proves the home appended an entry, not when. A recovery fork can't remove those entries. Keep your identity: the recovery code still outranks every device. It removes the thief's devices and moves you to another homeserver with new entries on top of the chain (§6.4.1, §6.6). Expect no alerts from a homeserver working with the thief: they come from it (§6.4.1).
Someone with your recovery code Everything. They own the identity and can open the backup (§17.3). Read messages from before they added a device, unless they also restore your backup.
A spammer with free identities Create as many identities as they like, and send contact requests to anyone whose profile publishes an invite code, within the rate limits (§9.5). Reach anyone who hasn't published or given them a code. Message anyone who hasn't accepted them. Add anyone to a group (§10.3).
A group member who isn't an admin Propose adding their own contacts, and send messages. Add anyone without an admin's signed approval, or remove other members (§11.8).
A future quantum computer Forge Ed25519 signatures once it exists: device-signed chain entries (undone by the recovery key within 72 hours), server keys, and epoch-key signatures (freezing groups) (§3.1). A forged add-device lets it read future messages in that person's chats until the device is noticed and removed (alerts and system lines make it visible). Decrypt recorded chats or calls: key exchange is hybrid ML-KEM + X25519. Take over identities through the recovery key, which is never published before use (§6.3).
A network observer See which servers talk to which, plus timing and sizes. See IP addresses talking to servers, but not to each other's devices, because calls are relayed by default (§15.2). Read content (TLS, plus E2EE where it applies).
The link domain Serve a malicious link page, or go down. Change what a code means: links are stateless, and the code is checked against the named server (§9.4).
Whoever controls a handle's domain Point the handle at a different identity. Clients then show "handle no longer valid" for the old identity (§5.3). Take over the identity behind the handle, its contacts, or its groups. Those follow the identifier.
Someone who can spoof DNS to a resolver Point a handle published only as a DNS record at their own identity, for resolvers that don't validate DNSSEC (§5.3). Take over a handle that has an HTTPS document, or the identity behind any handle.

17.3 Recovery code

Whoever has the recovery code owns the identity, and can also open the backup. Clients MUST explain this at setup, MUST NOT store the code, and SHOULD ask the user to type it back to confirm they've saved it. A client MAY let the user skip the check; a user who skips it finds out their copy was wrong only when they need it.

There's no email reset. A user who loses every device and the recovery code has lost the identity, and the backup with it. Downloading the backup requires signing in, and signing in requires a device or the recovery code. (Letting anyone download a backup with only the passphrase would let anyone guess at it offline.)

Rotating to a new recovery code is defined in §6.3: type the old code in once to sign, and commit to the new one. Clients SHOULD offer it from settings, and SHOULD suggest it after the old code has been used. A minimal client can leave rotation out; its users then keep a recovery code that has been used, and possibly seen.

17.4 Needs outside cryptographic review

These parts are Tieroute's own constructions on top of standard primitives. They aren't safe to rely on until someone with cryptographic expertise has reviewed them:

  1. The fork rule (§6.4.1): the 72-hour override, the clock checks, and the handling of two recovery-signed branches.
  2. Epoch keys and the hub (§11.6): exporter-derived signing keys, next handoff and the reg fallback, signed commit receipts, and stored commits.
  3. Verification codes (§6.7): commit-then-reveal rounds, 40-bit codes, and what goes in the transcript.
  4. Backup and account key wrapping (§7.4, §7.5): the recovery and passphrase wraps, the Argon2id parameters, and the offline-guessing exposure.
  5. Post-quantum design (§3.1): the choice of hybrid MLS and HPKE suites while they're still drafts; recovery-key pre-rotation and its in-flight window (§6.3); SFrame for calls keyed from MLS (§15.2); and the plan for hybrid ML-DSA signatures.
  6. Signing JSON (§4.3): the body-as-text envelope, JCS for signature inputs and hashes, and base64url strictness.
  7. Add approvals (§11.8): the approval inside the encrypted extension update, the used list, and expiry measured against the hub receipt's ts.
  8. Who vouches for chain entries (§6.5): previous-home confirmation for device-signed entries, and recovery-signed entries from anywhere.
  9. Ed25519 verification rules (§4.3): strict scalar, the small-order list, and the cofactorless equation.

MLS, SFrame, HPKE, and Ed25519 themselves are standards and aren't on this list. How Tieroute combines them is.

18. Privacy considerations

This section lists what each party can learn. Message content, files, group details, and call media are end-to-end encrypted (§17.1); everything below is what's left.

18.1 Accepted trade-offs and metadata

Servers see more metadata than the ideal, and that's accepted for v1: they can see who talks to whom but never what's said. It's a deliberate trade for simplicity:

  • from on deliveries tells the receiving server who is talking to whom. It's needed for allowlists.
  • Homeservers know each user's contacts and groups (the allowlists).
  • Presence state and status messages are visible to homeservers, and public profiles are visible to anyone (§13).
  • Signed messages are provable to third parties. There's no deniability (§16.2).
  • After you lose every device, your homeserver can roll your account data back, which hides withheld backup segments (§7.5).
  • JSON is larger than a binary encoding: base64url adds a third to every MLS message and key. That's accepted in exchange for a format anyone can read (§4).
  • A handle can be published as a plain DNS record, which anyone able to spoof DNS to a resolver can forge, unless the domain uses DNSSEC and the resolver validates it. That's accepted so a handle needs only a DNS record; an owner who wants more publishes the HTTPS document instead (§5.3).

Not in v1. Sealed-sender-style schemes, which would hide from from the receiving server, may be evaluated for a later version.

Out of scope. Tieroute doesn't try to protect against:

  • People you chose to talk to. A contact or group member who turns out to be abusive can screenshot, forward, or report what you sent them. Tieroute's tools for that are block, remove, report, and leaving the group, not cryptography.
  • A fully compromised device or OS. Malware with your device's keys is a thief with your device (§17.2), and anything on the screen can be captured.
  • Traffic analysis by a network observer or a server, beyond what relayed calls already hide.
  • Denial of service by servers you depend on: your homeserver, or a group's hub. Tieroute's answer is that you can leave: move homeservers, or re-create the group.
  • Coercion of a user to hand over their devices or recovery code.

18.2 What each party can see

Party Sees Doesn't see
Your homeserver Your identity chain, contacts, groups, invite codes (as keyed hashes), presence and status message, who you send to and when, delivery sizes, and your IP address Message content, files, group names, call media, your account data, and your backup
A contact's homeserver That you talk to its user, when, and how much; your public profile and presence, if you're mutual contacts Content
A group's hub That a group exists, its epoch, and when commits happen Who's in the group, or anything inside a commit (§11.6)
Any server that relays a commit The commit's envelope and size Who was added or removed: proposals, approvals, and the group extension are inside the encrypted commit (§11.8)
A network observer Which servers talk to which, timing, and sizes; which IP addresses talk to which servers Content (TLS, and E2EE inside it), and call peers' IP addresses, because calls are relayed by default (§15.2)
The link domain Which invite links are opened, from which IP addresses Whether a code is valid, or anything else about you (§9.4)
Anyone Your public profile, and an invite code you choose to publish on it (§13.1) Your status message, contacts, and presence

A community server operator, in a later version, would see its members, channel occupancy, and channel text.

19. Interoperability and versioning

19.1 Versions

  • The protocol is versioned MAJOR.MINOR; this document is 1.0. A MINOR version only adds things older implementations of the same MAJOR version can ignore, so it never appears on the wire. The wire version is the MAJOR number, 1 for this document. It appears in the server document's apis (§5.1), the signature context ("tieroute/v1"), and the WebSocket subprotocol (tieroute.v1). It isn't in any fixed path: API locations come from the server document (§8.1).
  • Two servers use the highest version they both list. A breaking change means a new MAJOR version.
  • A deprecated feature is marked with the version that deprecated it and the version that will remove it. Nothing in 1.0 is deprecated.
  • Wire version 0 (CBOR, through the v0.1 core) is retired. v1 servers don't need to speak it.
  • v1 is frozen once the outside review (§17.4) is done. Until then it can still change. After that, every change is logged (§19.4), and breaking changes wait for version 2.

19.2 Extensibility

  • Decoders MUST ignore unknown members in unsigned objects and in the bodies of most signed objects. (The signature covers the text of b, so ignoring a member never breaks verification.)
  • Except where state depends on them. The bodies of chain-entry (and each chain op), commit-request, commit-receipt, epoch-key, add-approval, contact-request, move, and s2s-request, and the tieroute_group extension (§11.2), which isn't signed but is state every member has to agree on, are closed (critical, in versioning terms): a decoder MUST reject one with a member it doesn't know, or a member that doesn't belong to that op. A server answers 400 bad-request; a client, which is what decodes tieroute_group, drops the commit (§1.1). Otherwise an older implementation would silently ignore a new field and compute a different state from everyone else. Adding a member to these needs a new wire version.
  • An unknown value of a closed set (a delivery-kind, a chain op, a signed type tag) MUST be rejected with 400 unknown-kind, which tells the sender the receiver may be on an older version. New values come with a new version.
  • Unknown content types MUST be displayed as a placeholder (§12).
  • Unknown server roles and unknown code forms are ignored or reported as errors (§5.1, §9.4), so later versions can add community servers without breaking v1 clients.

19.3 Conformance

  • One reference implementation, rerouted (Go), a conformance suite, and separate badges for servers and clients.
  • Server conformance: tested over HTTP against the client and S2S APIs. Covers discovery, chain validation, signed requests, invite codes, allowlists, hub sequencing, retries, and inbox behavior. No MLS needed.
  • A core server tier. A homeserver can be fully conformant without TURN (§15.1), encrypted backup (§7.5), or media hosting (§14). Those endpoints answer 501 not-implemented, and its users' clients don't offer calls through it, backups, or attachments. A small server for a few friends doesn't have to run a relay or store files.
  • Client conformance: test vectors for signed objects, chains, and the MLS profile, plus scripted interop runs against rerouted. The MLS vectors double as a way for client authors to check that their chosen MLS library supports the profile. Where the IETF MLS interop test vectors cover the profile, they're reused rather than duplicated.
  • The test vectors live in spec/vectors/, with an independent checker (spec/vectors/check/check.mjs). The conformance suite lives in conformance/. So far it has the attack suite: one test for each server-side claim in §17.2, run against two homeservers from outside, over HTTP (security claims and their evidence). The server and client badges come later.

19.4 Changing the spec

Once v1 is frozen, every change to the spec will be logged in the changelog (§22), with its date and reason. A change to the core (see the top of this document) also updates the test vectors. A change that breaks compatibility after v1 is frozen needs a new wire version (§19.1).

20. Test vectors

The test vectors, in spec/vectors/, give known inputs and the exact expected outputs. A Go program generates them from fixed inputs (spec/vectors/gen), and an independent JavaScript checker (spec/vectors/check/check.mjs) re-derives everything it can, with its own JCS. Every key in them is public: never use them for anything real.

File Covers
signed.json The signed envelope: the body as JCS text, the signature input, the signature, and the hash (§4.3)
ed25519.json The Ed25519 verification rules, including small-order and non-canonical points (§4.3)
recovery.json Recovery code derivation and pre-rotation commitments (§6.1, §6.3)
chain.json A valid identity chain, its identifier, and its final state (§6.2 to §6.4)
fork.json The fork rule: six cached-versus-candidate cases and their outcomes (§6.4.1)
codes.json A contact verification-code round (§6.7)
link.json Device linking: the link code, confirmation code normalization, and a link proof (§7.2)
account.json Account data: the wrapped account key and an account-data blob, with the associated data that binds each to its identity (§7.4)
vouch.json Who vouches for chain entries: previous-home confirmation, unreachable and 404 homes, first fetches, and recovery entries vouching for earlier ones (§6.5)
s2s.json An S2S request signed with a server key named by kid (§8.3)
formatting.json Message formatting: 30 bodies and the trees they parse to (§12.1)
sframe.json SFrame for calls: RFC 9605's own test case, header encodings, and Tieroute's key IDs with their keys and an encrypted frame (§15.2)

Not covered yet: MLS objects (use the IETF MLS interop vectors for the MLS layer), hybrid HPKE link bundles, and contact requests.

21. References

21.1 Normative references

  • BCP 14 (RFC 2119, RFC 8174): requirement keywords
  • RFC 4648: Base16, Base32, and Base64 encodings
  • RFC 5869: HKDF
  • RFC 7493: I-JSON
  • RFC 8032: Ed25519
  • RFC 8259: JSON
  • RFC 8610: CDDL
  • RFC 8785: JSON Canonicalization Scheme (JCS)
  • RFC 9106: Argon2
  • RFC 9180: HPKE
  • RFC 9420: Messaging Layer Security (MLS)
  • RFC 9605: Secure Frame (SFrame)
  • FIPS 203: ML-KEM
  • draft-ietf-mls-pq-ciphersuites-07: post-quantum MLS ciphersuites
  • draft-ietf-hpke-pq: post-quantum and hybrid KEMs for HPKE, including MLKEM768-X25519 (X-Wing)
  • BIP-39: the English word list and checksum, used for recovery codes

21.2 Informative references

  • RFC 6189: ZRTP, the model for commit-then-reveal verification codes
  • RFC 6761: special-use domain names (.test)
  • RFC 7515: JSON Web Signature, considered and rejected (§4.3)
  • RFC 9052: COSE, considered and rejected for v0
  • did:plc: the model for the fork rule (§6.4.1)
  • zxcvbn: the password strength estimator used for backup passphrases (§7.5)
  • IETF MIMI working group (More Instant Messaging Interoperability)
  • Example MLS implementations, neither endorsed nor required: OpenMLS (https://github.com/openmls/openmls), mls-rs (https://github.com/awslabs/mls-rs), and MLS++ (https://github.com/cisco/mlspp)

22. Changelog

There's no version history yet. This document is a draft, and the changelog starts when v1 is frozen (§19.1). From then on, every change will be listed here with its date and reason.


Appendix A. Endpoint index

Well-known (GET, unsigned): /.well-known/tieroute/server, /.well-known/tieroute/handle (on the handle's domain). DNS: _tieroute.{handle} TXT.

Client API (relative to the server document's client base, §8.1; bearer token except register, auth/*, and link/proof):

Endpoint Section
POST register §8.2
POST auth/challenge, POST auth/session, POST auth/logout §8.2
GET stream (WebSocket), GET inbox, POST inbox/ack §8.2, §10.4
POST send §10.2
GET chain/{id}, POST chain/append §6.4
GET resolve/{handle} §5.3
POST recover §6.3, §17.3
POST link/proof, GET link/proof/{device} §7.2
GET servers/{name} §11.6
POST keypackages/upload, GET keypackages/count, POST keypackages/claim §11.4
POST contacts/request, POST contacts/accept, POST contacts/decline, POST contacts/cancel, POST contacts/remove, POST contacts/block, POST contacts/unblock, GET contacts §9
POST invites/create, POST invites/revoke, GET invites §9.1
POST groups/create §11.5, §11.6
POST groups/register, POST groups/unregister §10.3
POST groups/epoch-key §11.6
GET groups/{group}/commits §11.6
POST presence §13.2
POST media/upload, GET media/{server}/{id} §14
POST profile, GET profile/{id}, POST media/upload-public §13.1
GET account/keys, POST account/keys, GET account/data, POST account/data §7.4
POST backup/put, GET backup/list, GET backup/get §7.5
GET turn §15.1
POST report §16.3

S2S API (relative to the server document's s2s base, §8.1; all POST with a signed s2s-request):

Endpoint Section
deliver §10.2
commit, commit/fetch, group/register, group/epoch-key §11.6
identity/fetch, identity/push §6.5
keypackages/claim §11.4
contacts/request §9.2
contacts/decline §9.3
presence §13.2
media/fetch §14, §13.1
profile/fetch §13.1
report §16.3

© 2026 Reroute Holdings LLC

A public draft of Tieroute v1, open for comments.