Server-to-server v1
Server-to-server v1version 1status: implemented, normativerim-server/docs/specs/server-to-server-v1.mdcommit 98d1883
Status: implemented, normative. Follows docs/decisions/decentralization-2026-10-08.md and
docs/decisions/relay-proxied-workspaces-2026-10-09.md. Read key-identity-v1.md,
device-linking-v1.md and e2ee-mls-v1.md first. This document is the contract between servers
and between a server and its clients: server identity, the signed HTTP API between servers,
relays, hosts, and what the client verifies. Labels of the form S5-n below name the change that
introduced an item.
Summary #
- Every rim-server has an Ed25519 server key in a signed key log whose inception fixes a
rims1_server id, published with a signed descriptor atGET /.well-known/rim-server. - Servers talk over HTTPS with HTTP Message Signatures (RFC 9421) over a fixed component list
with
Content-Digest(RFC 9530), a 5-minute window and a nonce store. Responses are signed too. A minimal, vector-tested profile lives in a newrim_s2scrate; no published crate fits. RIM_ROLE=relay|host|both, defaultboth: with no new configuration a server behaves as today, and server-to-server traffic stays off untilRIM_PUBLIC_BASE_URLis anhttpsURL.- Each relay has one direct space, a
serverrow of kinddirectholding the DMs and groups its users start with anyone. The relay hosts them: sequencer, MLS delivery service, change feed. A DM's home is its creator's relay. - Hosts know people from elsewhere as
userrows of kindremote, keyed by principal id, with key log, devices and card copied from the person's relays and kept current. - A client talks only to its relay. Requests for a space hosted elsewhere go to
/api/remote/<host server id>/…with a device-signed request statement; the relay forwards them unchanged; the host answers with a host-signed response the client checks. - Hosts sign every event they send to relays (
host-event, per-stream sequence numbers). Relays queue events per user with per-device cursors (30 days at most) and push them over the client's existing WebSocket; the client verifies them against the host key pinned from the invite. - Workspace traffic reaches a relay only under a delegation a device signed (relay, host, workspace, at most 24 hours); direct-space traffic goes to the relays the person's card lists.
- A device revocation fans out from the relay to every host with a signed acknowledgement; the app sends it directly to a host that has not acknowledged within 2 minutes.
- When the relay is down the desktop app logs in to a host directly with its device key. A public relay gates registration with proof-of-work or an invite.
Parties and terms #
| Term | Meaning |
|---|---|
| Server | One rim-server deployment: one server key, one database, one or more nodes |
| Relay | The server a person's app talks to; their user row there is kind = local |
| Host | The authority for a space: a workspace, or a relay's direct space |
| Space | A server row: kind = workspace or kind = direct |
| Peer | Another server this one has talked to |
| Remote user | A user row of kind = remote on a host: a person whose relay is elsewhere |
A server running both hosts its own workspaces and direct space and is the relay of its local
users. Today's single server is exactly that and stays that with nothing configured.
Server identity #
Server key log #
The rules of a person's key log apply (key-identity-v1.md, "Signed statements": exact signed
text, unknown and duplicate fields refused, at most 256 entries), in version 2 only. Context
bytes rim.server-key.v1\0, payload at most 16 KiB.
{"v":2,"type":"server-inception","seq":0,"serverKey":"ed25519:…","recoveryKey":"ed25519:…","createdAt":"2026-10-09T00:00:00Z"}
{"v":2,"type":"server-rotate","serverId":"rims1_…","seq":1,"prev":"…","serverKey":"ed25519:…","recoveryKey":"ed25519:…","signer":"server","createdAt":"…"}
serverId = "rims1_" + base64url(SHA-256("rim.server.v1\0" || inception payload bytes))
keyHash = base64url(SHA-256(tagged key text)) (as device_key.key_hash)
- The inception is signed by its
serverKey."signer": "server": signed by the current server key, recovery key unchanged."signer": "recovery": signed by the current recovery key, which may change.seq,prevand "the key must change" are as for a person's log. - Private keys never touch the database: the server key's 32-byte seed is in
RIM_SERVER_KEY_FILE(default<RIM_DATA_DIR>/server-key.json,{"v":1,"seed":"<base64url>"}); the server refuses to start if the file is readable by group or others, or if file and log head disagree. The log is inserver_key_log_entry. - First boot (S5-1) with neither file nor log: the server creates both keys, the inception and
the file, and writes the recovery seed to
<RIM_DATA_DIR>/server-recovery-key.txt(0600) asrim-server-recovery:1:<base64url>, logging a warning at every start while it exists ("move the server recovery key offline"). - CLI (S5-1, beside
invite_cmd.rs):rim_server server-key show,server-key rotate(asigner: serverrotation; the old seed is overwritten after the entry commits),server-key recover --recovery-key-file F [--new-recovery-key]. The new seed is written to<key file>.next(0600) before its entry commits and moved over the key file after; a start that finds the log head naming the.nextseed moves it into place. A running server checks the log every minute and switches to the new key, re-signing its descriptor.recoverworks without the key file; with--new-recovery-keyit writes the new recovery key where first boot does, refusing to overwrite a file there.
Discovery document #
GET /.well-known/rim-server, unauthenticated, Cache-Control: max-age=300,
Access-Control-Allow-Origin: *:
{"rim":"server-discovery","keyLog":[{"payload":"…","signature":"…"}],"descriptor":{"payload":"…","signature":"…"},
"features":{"attachments":{"state":"enabled"},"email":{"state":"unavailable","reason":"…"},"custom_emoji":{"state":"disabled"},…}}
features is optional and unsigned: each optional feature of the instance (the names of
RIM_FEATURES_DISABLED, docs/operations/features.md) with its state, enabled,
disabled or unavailable, and a reason for an unavailable one. It is advisory, for clients
deciding what to offer; the server enforces the switches itself, so a changed copy only changes
what a client shows. Readers ignore a missing features and names they do not know. It sits
beside the descriptor rather than in it because the descriptor refuses unknown fields: an
earlier server would refuse a descriptor that carried it.
The descriptor is signed by the head server key over rim.server-descriptor.v1\0 || payload
(at most 16 KiB):
{"v":2,"type":"server-descriptor","serverId":"rims1_…","keySeq":0,"version":1,"roles":["host","relay"],
"endpoints":{"graphql":"https://host.example/api","websocket":"wss://host.example/api/ws","s2s":"https://host.example/s2s/v1"},
"s2sVersions":[1],"mlsCipherSuites":[79],"limits":{"maxBodyBytes":8388608,"clockSkewSeconds":300,"maxDeliverItems":256},
"registration":{"mode":"invite","powBits":null},"issuedAt":"…"}
| Field | Rule |
|---|---|
keySeq |
The log head; a descriptor signed by another key is refused |
version |
1 to 2³¹−1; readers keep only a higher one. Re-signed with version + 1 on any change and after every rotation |
roles |
Non-empty subset of host, relay, in that order |
endpoints |
One origin in v1; s2s null while server-to-server traffic is off |
s2sVersions |
[1]; a later version keeps serving the previous one (ROADMAP.md, "Releases and updates") |
mlsCipherSuites |
What uploadMlsKeyPackages accepts |
registration |
mode invite, open or pow; powBits only in pow mode |
The decision record wrote /.well-known/rim/server as an illustration; one well-known name is
simpler. Rejected: an unsigned descriptor (anyone proxying the fetch, a relay included, could change
endpoints), and signing the whole document (the log is self-certifying; one verifier per statement).
Pinning a peer #
A server learns a peer's id from a request's keyid, a delegation, a card's relay URL or an
invite. To pin it: fetch <origin>/.well-known/rim-server over HTTPS (5 s, 64 KiB, no redirects;
origins that are or resolve to a non-public address, as "Implementation" defines it, are refused
unless RIM_S2S_ALLOW_PRIVATE_ADDRESSES=true), replay the log, check the inception against the expected
id, verify the descriptor (its endpoints.s2s must start with the origin), and store everything in
peer_server. Refresh every 6 hours on use, on a keyid above the pinned head, and on a 401 from
the peer; at most one fetch per peer per minute and 60 in total.
| Fetched log | Action |
|---|---|
| Equal to the pin | Keep; take the descriptor if its version is higher |
| Extends the pin with valid rotations | Accept. After a signer: server rotation the previous key stays valid 24 hours from when this server accepted the extension; after a signer: recovery rotation it is refused at once |
| Does not extend it (a fork) | Status forked: requests either way fail 403 peer_forked, ERROR log, audit s2s.peer_forked, until rim_server peer reset <id> after an out-of-band check |
| A prefix of the pin | Keep the pin (the peer serves an older view, say a lagging node) |
blocked is an operator's decision (rim_server peer block <id>, S5-6): requests either way
fail 403 peer_not_allowed from the next one, until rim_server peer unblock <id> makes the peer
active again. Only an active peer is blocked and only a blocked one unblocked, so a forked
peer stays forked; a server never pinned is refused with RIM_S2S_DENIED_PEERS instead. Audit
s2s.peer_blocked, s2s.peer_unblocked.
A first pin keeps the key before the head but gives it no grace: the grace counts from an extension this server saw. A refetch whose inception hashes to another id (the origin now serves another server) fails and keeps the pin; only a log from the same inception can fork it.
Host keys in clients #
- From an invite (
rim-invite:1:<hostServerId>:<hostKeyHash>:<inviteToken>:<hostOrigin>): the app fetches the discovery document through its relay (remoteServer(origin)), replays the log, checks the inception hashes tohostServerIdand thathostKeyHashnames a key of the log followed only bysigner: serverrotations, then pins id and log. - Trust on first use for a contact's relay (the home of a DM someone else started). DM content is MLS-protected either way; the pin protects metadata events.
- A signature at a
keySeqabove the pin makes the app refetch (at most once a minute) and accept only an extension. The key before the head keeps verifying 24 hours after the app accepted asigner: serverrotation, never after asigner: recoveryone, and a first pin gives it no grace, as for servers ("Pinning a peer"). A fork stops the app using that server: "This server's identity changed in a way RIM cannot verify".
The invite carries the host's existing invite token, not one the host signed: the server id is a hash of the host's inception, so id plus key hash already expose a substituted host, and a signature over the token would add nothing.
The S2S transport #
Signature profile #
Requests go over HTTPS (http only with RIM_S2S_ALLOW_HTTP=true) to paths under the peer's
endpoints.s2s, signed per RFC 9421 under the label sig1:
| Request | Covered components, in order |
|---|---|
With a body (POST) |
"@method" "@target-uri" "content-digest" "content-type" "rim-origin" |
Without (GET) |
"@method" "@target-uri" "rim-origin" |
Parameters, in order: created; expires (at most created + 300); nonce (16 random bytes,
unpadded base64url); keyid ("<serverId>#<seq>", the signing key's log entry); alg (the key's
algorithm name, "ed25519"); tag ("rim-s2s-v1").
Content-Digest: sha-256=:<base64 SHA-256 of the body>:(RFC 9530, padded base64);sha-256must be present and match, other algorithms are ignored.Content-Type: application/json, except forwarded requests, which keep the client's.Rim-Origin: the sender's origin, where its discovery document lives.@target-uriis the absolute URI the sender used. The receiver rebuilds it from the origin of itsRIM_PUBLIC_BASE_URLplus path and query, never fromHostor forwarded headers, so a TLS-terminating proxy does not change it.- The base is RFC 9421 §2.5:
"<component>": <value>lines, then"@signature-params", joined by\n, no trailing newline (A.4).Signature-InputandSignatureare RFC 8941 dictionaries; other labels are ignored.
The nonce parameter is RFC 9421's own replay field and is covered by @signature-params.
Rejected: a separate rim-nonce header (a second field for one purpose) and Date (second
resolution; created is covered already).
Verifying a request #
In order, stopping at the first failure:
- Body at most
RIM_S2S_MAX_BODY_BYTES(8 MiB; 32 MiB on/forward/) →413 too_large. - Exactly the profile's components and parameters;
created ≤ now + 300,expires > now,expires − created ≤ 300→401 bad_signature. - Peer: denied,
blockedorforked→403; an unknown id is pinned fromRim-Originfirst (403 unknown_peeron failure). The host allow-list applies here ("Configuration"). keyid's seq is the pinned head, or the previous key in its grace period; a higher seq refreshes the pin once. Digest matches; signature verifies (verify_strict) → else401.- Insert
(peer, nonce, expires_at = created + 600 s)intos2s_nonce; a conflict is409 replay. Expired rows are deleted every minute. - Rate limits per peer →
429withRetry-After.
Responses #
Every response from the RIM layer, errors included, is signed by the responder, covering
"@status" "content-digest" "content-type" "rim-request-nonce" "@method";req "@target-uri";req
plus "content-digest";req when the request had a body, with created, keyid, alg, tag.
Rim-Request-Nonce echoes the request's nonce and binds the answer to it. RFC 9421 §2.4 advises
against covering the request's Signature field, hence the echo. A caller treats an unsigned or
badly signed response as a transport failure (retry), never as an answer, and refuses a response
whose created is more than 300 s from its clock. A refusal of a request whose nonce cannot be
read echoes an empty Rim-Request-Nonce; no caller can match it. While server-to-server traffic
is off, /s2s/v1/… answers an unsigned 404.
Errors #
Bodies are {"error":"<code>","message":"…"}.
| Status | error |
When |
|---|---|---|
| 400 | bad_request |
Malformed JSON or statement |
| 401 | bad_signature |
Profile, clock, key, digest or signature |
| 403 | unknown_peer, peer_not_allowed, peer_forked, not_delegated, forbidden |
Peer not pinnable; denied or off the allow-list; forked; no delegation or card names the relay; action refused |
| 404 | not_found |
Unknown principal, space or conversation |
| 409 | replay, conflict |
Nonce seen; device request seq refused (highestSeq in the body); the action conflicts with stored state |
| 413, 429 | too_large, rate_limited |
Caps; Retry-After |
| 500 | internal |
The responder failed (its database, say); retry |
| 503 | role_disabled |
The endpoint belongs to a role this server does not run |
A request in a method other than GET or POST fails the profile (401). A peer with status
blocked gets 403 peer_not_allowed.
Ping #
GET /s2s/v1/ping → 200 {"serverId":"rims1_…","roles":["host","relay"]}, served by every role.
It runs the whole request check and signs its answer, so it tests a pin and the transport both
ways.
Implementation #
New crate rim_s2s (no database, no Actix): signature bases, signing and verifying this
profile, Content-Digest, and the server-signed statements. Structured fields through sfv
0.15.0 (2026-06-16; RFC 8941/9651); Ed25519 through rim_identity's key encodings. Its tests
check RFC 9421 appendix B.2.6 ("Signing a Request Using ed25519") and appendix A here byte for
byte. Outgoing requests use reqwest with rustls behind a signing client in rim_infra_services.
Outgoing requests ignore proxy environment variables and connect only to public addresses: an IP
literal is checked before the request, a name when it resolves, and the connection uses the
checked addresses. Refused in IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16,
198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24 and 224.0.0.0/3 (multicast, reserved,
broadcast). In IPv6 only global unicast (2000::/3) is public, less 2001::/23 (Teredo,
benchmarking, ORCHID), 2001:db8::/32 and 3fff::/20; IPv4-mapped (::ffff:0:0/96),
IPv4-compatible (::/96), NAT64 (64:ff9b::/96) and 6to4 (2002::/16) addresses are judged by
the IPv4 address they embed, and local-use NAT64 (64:ff9b:1::/48) is refused.
Considered on crates.io, 2026-10-09: httpsig 0.0.26 (2026-07-22, MIT; Ed25519,
Content-Digest, response binding, but its README says work in progress and hyper-only
integration); web-bot-auth 0.7.0 (2026-04-22, Apache-2.0, Cloudflare; built for the Web Bot
Auth profile, generic use undocumented); actpub-httpsig (ActivityPub-shaped). A fixed profile
with one algorithm is about 400 lines against vectors, a smaller surface than any of them, and fits
Actix's request types.
Roles and configuration #
RIM_ROLE |
Client API | S2S endpoints |
|---|---|---|
both (default) |
Everything | All |
relay |
All but createServer (FORBIDDEN); existing workspaces keep working for local users |
Discovery, ping, principals, identity, key-packages/claim, deliver, presence, and host endpoints (identity, outbox, forward) for its direct space only |
host |
All but /api/remote/…, contacts, DM requests and remote spaces; local users DM inside workspaces |
Discovery, ping and host endpoints; deliver answers 503 |
| Variable | Default | Meaning |
|---|---|---|
RIM_PUBLIC_BASE_URL |
unset | S2S is on only when it is https (or http with RIM_S2S_ALLOW_HTTP); its origin is Rim-Origin and the base of @target-uri |
RIM_SERVER_KEY_FILE |
<RIM_DATA_DIR>/server-key.json |
The server key seed |
RIM_HOST_ALLOWED_RELAYS |
empty = any | Server ids allowed on host endpoints (join, subscriptions, forward, revocations, presence) |
RIM_S2S_DENIED_PEERS |
empty | Server ids refused everywhere |
RIM_DM_INBOUND |
requests |
DMs from other servers and from strangers here: open, requests, partners, none ("The request inbox") |
RIM_DM_OUTBOUND |
allowed |
DMs local users start with other servers: allowed, partners, none |
RIM_DM_PARTNERS |
empty | Server ids the partners settings name |
RIM_S2S_PEER_RATE_PER_MIN |
600 | Requests per peer (in memory, per node) |
RIM_S2S_SUBSCRIPTIONS_PER_PEER |
10000 | Active delegations per relay on a host |
RIM_S2S_DEVICE_RATE_PER_MIN |
120 | Forwarded requests per device |
RIM_S2S_MAX_BODY_BYTES |
8388608 | Body cap (forward path: 32 MiB) |
RIM_RELAY_QUEUE_BYTES_PER_USER |
268435456 | Queued bytes per local user |
RIM_RELAY_RETENTION_DAYS |
30 | Queue and DM request retention |
RIM_REGISTRATION_POW_BITS |
unset | Turns on pow registration (1 to 40); wins over RIM_OPEN_REGISTRATION |
RIM_RELAY_CARD_MAX_BYTES |
131072 | Largest card a local user publishes (at most 128 KiB) |
RIM_RELAY_KEY_PACKAGES_PER_DEVICE |
100 | Unclaimed one-time key packages per device and suite |
RIM_RELAY_DEVICES_PER_USER |
32 | Devices per local user, revoked ones not counted |
RIM_RELAY_FORWARDS_PER_USER_PER_DAY |
50000 | Forwarded requests per local user per day; per device per minute is RIM_S2S_DEVICE_RATE_PER_MIN, on the relay as on the host |
RIM_RELAY_DM_REQUESTS_PER_DAY |
20 | DM requests per sender principal per day on this relay, and invites per local user per day to people on other servers who are not their contacts |
The existing config loader (Environment::with_prefix("RIM")) maps RIM_ROLE to the field
role. From S5-1 on a login proof's audience may also be this server's id, compared exactly
(base64url is case-sensitive), beside RIM_KEY_LOGIN_AUDIENCES.
Remote users on a host #
Created the first time a host accepts a person from elsewhere (a join, a DM a local user starts, a delegation):
| Column | Value |
|---|---|
kind, principal_id, identity_key |
remote; from the verified key log |
username |
r_ + the first 30 characters after rimp1_. From S5-1 on local signups refuse the r_ prefix, so nobody squats a principal's name; existing local names are left alone |
display_name |
The card's displayName, updated with each newer card |
password_hash, email |
Null (allowed: principal_id is set) |
home_relay |
Server id of the relay that most recently presented a valid delegation for the user, else the first relay of their newest card that pins, else the relay that served their identity; null for local users |
Key log, devices (with certificates and revocations) and card live in the existing
key_log_entry, device_key and profile_card tables, as for an enrolled local user. A key-only
signup for a principal held here as remote turns the row local and keeps its memberships.
Usernames stay unique across the table; rejected: a partial unique index on local users only,
since login and member lookups assume one row per name.
Remote users may not run mutations that change the account rather than a space (FORBIDDEN, not available to remote members): login, logout, signup*, enrollIdentityKey, addDeviceKey,
revokeDeviceKey, rotateIdentityKey, publishProfileCard, logoutOtherSessions, password and
email mutations, createServer and bot mutations. Identity changes arrive by POST /identity and POST /revocations.
updateMyProfile sets what the host stores per member and the card does not carry: the avatar
(uploaded with POST /api/files/avatar on the forward path, then named by avatarUrl), status
text and bio. A displayName in it is FORBIDDEN (a remote member's display name comes from their profile card); the name changes with the next card the host receives.
Statements added by this document #
All follow key-identity's statement rules, version 2 only. Timestamps are informational, except a
delegation's expiresAt, which the host enforces against its own clock.
| Statement | Context bytes | Signed by | Max payload | PR |
|---|---|---|---|---|
| Server key log entry | rim.server-key.v1\0 |
server key or server recovery key | 16 KiB | S5-1 |
| Server descriptor | rim.server-descriptor.v1\0 |
head server key | 16 KiB | S5-1 |
| Device request | rim.device-request.v1\0 |
device key | 16 KiB | S5-2 |
| Host response | rim.host-response.v1\0 |
host server key | 16 KiB | S5-2 |
| Host event | rim.host-event.v1\0 |
host server key | 512 KiB | S5-2 |
| Delegation | rim.delegation.v1\0 |
device key | 16 KiB | S5-4 |
| Revocation acknowledgement | rim.revocation-ack.v1\0 |
host server key | 16 KiB | S5-4 |
Device-signed statements are verified in rim_identity against a key log, server-signed ones in
rim_s2s. Both take the algorithm from the tagged key, so a hybrid key is added in one place.
Spaces hosted elsewhere: the client's path #
Device-signed requests #
A request to a space hosted elsewhere carries Rim-Device-Request (unpadded base64url of the exact
payload text) and Rim-Device-Signature (the tagged signature over
rim.device-request.v1\0 || payload):
{"v":2,"type":"device-request","principalId":"rimp1_…","deviceId":"<uuid>","host":"rims1_…",
"method":"POST","path":"/api","seq":1791504000000000,"bodySha256":"<base64url>","issuedAt":"…"}
| Field | Rule |
|---|---|
host |
The host's server id |
method, path |
The host-relative request (POST /api, GET /api/attachments/<id>/download, POST /api/files/upload, POST /api/files/avatar, GET /api/files/<path>, GET /api/plugin-bundles/<server>/<sha256>); must equal the forwarded one |
seq |
Below 2⁵³, greater than every seq this device used with this host; clients use max(previous + 1, Unix time in µs) and persist it |
bodySha256 |
base64url(SHA-256(exact body bytes)); the empty body's hash for GET |
The host accepts a seq it has not accepted from this device in the last 10 minutes and that is
above highestSeq − 300,000,000 (5 minutes in µs), then raises highestSeq. An accepted seq is
remembered for 10 minutes and, after that, until it falls below the window: forgetting it at 10
minutes would let a relay replay a device's latest request once the device went quiet. Otherwise
409 {"error":"replay","highestSeq":"<decimal>"}; the client moves its counter above it and signs
once more. The window tolerates the relay reordering concurrent requests and needs no server clock.
Rejected: a strict +1 counter (a crash between signing and persisting locks the device out;
concurrent requests fail).
Forwarding #
The relay serves GET|POST /api/remote/<hostServerId>/<host-relative path> (a non-GraphQL
transport in rim_server, in the file_handlers.rs pattern); GraphQL is
POST /api/remote/rims1_…/api. It authenticates the session cookie (and CSRF for POST) as today,
refuses a device request naming another principal or device than the session's or a revoked
device (FORBIDDEN), and sends <host s2s>/forward<path> with the body byte for byte, the client's
Content-Type, both device headers and its own signature. It returns the host's status, body,
Content-Type, Rim-Host-Response, Rim-Host-Signature and Retry-After unchanged.
The host's /s2s/v1/forward/… checks the S2S signature, then the device request (a non-revoked
device of a user here; host is its own id; method, path, body hash; seq), then that the relay
may carry this user's traffic: it holds an active delegation of this device for a workspace here,
or the user is a member of this host's direct space and the relay's origin is in their newest card
(403 not_delegated otherwise). It runs the normal handler (GraphQL, attachment upload and download,
avatar upload, stored-file download, plugin bundle download) as that user and device, with no session; CSRF does not apply without cookies. Per-IP limiters key on
relay:<serverId>:<principalId> for forwarded requests.
Every forwarded response carries Rim-Host-Response (base64url payload) and Rim-Host-Signature:
{"v":2,"type":"host-response","host":"rims1_…","keySeq":0,"requestSha256":"<base64url>","status":200,"bodySha256":"<base64url>"}
requestSha256 hashes the device-request payload text, so a relay cannot answer a new request with
an old response. The app checks signature, pin, request hash, status and body hash; a failure counts
as a relay failure ("Direct connection"). The host buffers bodies to hash them (downloads ≤ 25 MB).
Coexistence. Spaces this server hosts are used exactly as today (/api with cookie and
X-CSRF-Token, workspaceEvents on /api/ws); only spaces hosted elsewhere use /api/remote/….
The request authenticator gains a second path (device request via a pinned peer) yielding the same
RequestAuthResultDto with no session id; AuthActorCtx already carries the device. Rejected: one
S2S endpoint per client operation (hundreds of endpoints drifting from the schema, while the
decision requires writes to reach the host unchanged).
Host events and relay queues #
Host events #
{"v":2,"type":"host-event","host":"rims1_…","keySeq":0,"workspaceId":"<uuid>","stream":"c:<uuid>","seq":42,
"event":{"type":"MessageDeleted","workspace_id":"…","conversation_id":"…",…},"issuedAt":"…"}
event is the WorkspaceEvent JSON clients parse today. Its ids are the host's; clients key every
entity of a remote space by (host server id, id).
stream |
Events | seq |
|---|---|---|
c:<conversationId> |
Every event with a conversation_id (messages, reactions, pins, threads, conversation membership, link previews, MlsCommitAccepted, MlsMembershipChanged) |
1, 2, … |
u:<principalId> |
Events for one user (NotificationDue, UserMentioned, ReadStateUpdated, MlsWelcomeAvailable, DmInvite); for a guest, the directory events about them |
1, 2, … |
w |
Other space-wide events (MemberJoined, MemberLeft, MemberRoleChanged, WorkspacePluginChanged, pipeline and workspace-state events), to non-guests |
1, 2, … |
e |
TypingStarted, PresenceChanged |
null |
seq is assigned per (workspace, stream) in host_event_stream, in the transaction that writes
the outbox rows. Recipients follow today's user_can_see_event_as, which S5-2 moves from rim_api
to rim_services for the outbox writer. streamHeads(serverId, streams) (host GraphQL, through
the forward path) answers the current seq of each stream the caller can see.
Outbox and deliver #
For each event the host appends one s2s_outbox row per receiving relay: relays holding an active
delegation of a recipient, or for a direct space the relays in each remote recipient's newest card.
A task per peer sends them in id order:
POST /s2s/v1/deliver
{"items":[{"id":"1234","envelope":{"payload":"…","signature":"…"},"recipients":["rimp1_…"],"deviceId":null}]}
→ 200 {"ackedThrough":"1234","refused":[{"id":"1230","reason":"unknown_principal"}]}
- At most 256 items and 8 MiB per request;
recipientsare the relay's users who may see the event;deviceIdtargets one device (MlsWelcomeAvailable). - The relay stores items before answering and ignores ids at or below its
received_throughfor the peer, so redelivery is idempotent. It refuses items for non-local principals (unknown_principal), items of a conversation it holds no invite or request for and workspace items without an active subscription (not_subscribed), invites from a principal the recipient blocked (blocked) or past the request limits (limited), a device-targeted item for an unknown device (unknown_device), and envelopes that do not verify under the delivering host's key or name another host (invalid_envelope). A refusal for one recipient carriesprincipalId. The host skips a (relay, principal) for 24 hours afterunknown_principalonly; the other refusals are about one conversation or item. - Failures back off from 1 s doubling to 5 minutes. Outbox rows live 7 days; a relay down longer
gets nothing and its clients catch up from the change feed. A relay may pull what it missed:
GET /s2s/v1/outbox?after=<id>&limit=256→{"items":[…],"hasMore":false}.
Relay queues #
The relay stores each envelope once (relay_envelope), one relay_item per recipient user, and a
cursor per device (relay_device_cursor): each device's queue is its user's items above its cursor.
- Push: a subscription
relayStream(after: ID)on the existing/api/wsyields items aboveafter, then live ones:{"type":"RelayItem","id":"981","hostServerId":"rims1_…","workspaceId":"…","envelope":{…}}, plus notices:{"type":"DmRequestReceived","requestId":"<uuid>"},{"type":"RevocationAcked","deviceId":"<uuid>","hostServerId":"rims1_…","ack":{"payload","signature"}}(the app checks the acknowledgement and refreshesdeviceRevocationStatus) and{"type":"QueueOverflow","spaces":[{"hostServerId":"rims1_…","workspaceId":"<uuid>"}]}. - Pull
relayItems(after, limit); ackackRelayItems(upTo)moves the session device's cursor. A newly linked device starts at the newest item and reads history from the host's feed and the archive. - Retention: an item goes once every non-revoked device of its user acked it, and after
RIM_RELAY_RETENTION_DAYS(30) regardless. Over the per-user byte cap the oldest go first and aQueueOverflownotice names the affected spaces. The notice is pushed to the devices online and not queued (a queued item is a host-signed envelope); a device offline then sees the gap in the streams'seq. Plaintext channel events are readable by the relay while queued, as the relay decision accepts.
What the client checks #
For every RelayItem: the signature under the pinned key at keySeq (refetching on a newer seq);
host is the item's; workspaceId is a space the app has from that host; the stream matches the
event (c: its conversation, u: the app's principal). Then per stream:
seq ≤ last: duplicate, dropped.seq = last + 1: applied.seq > last + 1: held up to 30 seconds or 256 events for the missing ones, then the app reconciles through host-signed responses (change feed, pins and reactions of the open conversation, members,streamHeads) and setslastto the head. Only if that fails does it show "Messages may be missing from <space>" with "Connect directly".- A stream's baseline is its
streamHeadsvalue when the space opens, or the first event seen. Events arriving by relay and by direct connection are deduplicated the same way.
Profile cards and contacts (7.4a) #
- Publishing.
relays[]is the person's own list, preferred first. The app signs a card (version + 1) and callspublishProfileCardon each listed relay it has an account on; each relay pushes it to every host that holds the person as remote (POST /s2s/v1/identity): the hosts it queued items or filed requests from for the person, and in S5-4 those holding their subscriptions. Device additions and revocations are pushed the same way. The answer is{"applied":true|false}. - Identity endpoint (relays, for local users):
GET /s2s/v1/principals/<principalId>→{"principalId","keyLog":[…],"devices":[{"deviceId","certificate","revocation","mlsActive"}],"card"},404for others.POST /s2s/v1/identitytakes the same JSON; the receiver applies it only for principals it holds as remote: a log extending its copy, new devices with valid certificates, revocations, a card of higher version. - Fetching.
remoteIdentity(principalId, relayUrl)answers local principals directly and otherwise fetches fromrelayUrl(pinning it), cached 24 hours. The app verifies the log against the principal id, each certificate against the log and the card against the head, ase2ee-mls-v1.mdrequires for leaves.principalDevicesanswers remote users from the stored copy, refreshed from their relay when older than an hour. - Contacts. A contact link is
rim-contact:1:<principalId>:<relayUrl>(relay URL rules as a device-link endpoint: 1 to 256 bytes of printable ASCII, no?,#,@), shown as a byte-mode QR code. The principal id authenticates everything fetched with it. Rejected: the card in the link (up to 128 KiB, too large for a QR code, stale after a relay switch). The app reads a shared workspace member's card withremoteIdentity(principalId)(the relay answers its own principals directly) to offer "Add as contact". The contact list answers open decision 9 (readable names): the app shows the card's name or the person's alias for it; aliases stay on the device. The relay keeps the list (relay_contact) because it filters DM requests by it.
The direct space #
Each relay has one server row with kind = 'direct', created at start if missing, named "Direct
messages", not listed by servers, with no owner, channels, invites or plugin registry;
relayInfo { serverId roles directSpaceId } gives its id. Every member is a guest, so today's
guest rules already hide the member list and directory events; the conversation service lets
guests create groups in a direct space and refuses channels there. Local users join on first use,
remote users when a local user adds them to a conversation.
A DM or group there has conversation.server_id = the direct space, so "a DM in no workspace"
belongs to its home relay's direct space and every table, event and query keyed by server_id
works unchanged. Rejected: a nullable conversation.server_id (every conversation query, every
event's workspace_id and the per-space subscription would change). DMs inside workspaces stay
possible; the app starts new DMs in the direct space when every member has a principal id.
DMs across relays (7.4a) #
Alice's relay R_A is the home; Bob's relay is R_B. Groups work the same way.
- Create. Alice calls
createConversation(serverId: <directSpaceId>, encrypted: true, remoteMembers: [{ principalId, relayUrls }])on R_A. R_A fetches Bob's identity from the first relay that answers, creates or refreshes his remote user row and direct-space membership, and checks readiness frommlsActive. A pair with a DM there gets it back, as today. One entry inremoteMembersand nomemberIdsmakes a DM, more make a group; the app names every member this way, those on R_A included. The app creates a conversation with anyone on another relay only encrypted: when a member or Alice's device is not ready it creates nothing and says who. The server enforces this too: R_A refuses such a conversation unencrypted (VALIDATION, "conversations with people on other servers are encrypted"), so relays only ever carry its ciphertext, and refuses an encrypted one when a member has no MLS-active device. A person from another relay may create one too, through the forward path, naming R_A's members the same way. - Key packages. Claims for Bob's devices go to R_B:
POST /s2s/v1/key-packages/claim{"principalId","devices":["<uuid>"],"conversation":{"host","conversationId","incarnation"},"claimer":{"principalId","deviceId"},"cipherSuite":79}(cipherSuiteoptional, the preferred suite when absent;conversation.hostmust be the calling peer) →{"keyPackages":[{"deviceId","keyPackage","keyPackageRef","lastResort"}]}, by today's claim rules. A claimer who is not Bob's contact, was never accepted by him, and shares no workspace R_B holds a subscription for, is a stranger: last-resort packages only, 20 claims per claimer principal per day. R_A stores the packages as claimedmls_key_packagerows of Bob's device rows, sosubmitMlsCommit's claim check runs unchanged. - Invite. When a remote member is added to a conversation of the direct space (at creation or
by
addConversationMembers), R_A emitsDmInviteon the member'su:stream,{"type":"DmInvite","workspace_id","conversation_id","from_principal_id","from_card","member_principal_ids"}, before the conversation's own events.from_*name the conversation's creator;from_cardis their published card as{"payload","signature"}(a conversation with remote members needs one), and its relays tell Bob's app where to pin R_A from. The Commit that adds the member's devices then emitsMlsWelcomeAvailableper added device. That is how R_B and Bob's devices learn of it; a plaintext conversation has an invite and no Commit. - Traffic. Bob's devices fetch Welcomes and Commits, read the change feed, send, edit, react
and mark read through
/api/remote/<R_A>/api; R_A runs them as Bob's device, and its events reach Bob's devices through R_B's queue. The envelopes are exactly themessage.encryptionR_A stores. - Sequencer. R_A alone: one Commit per epoch, the change feed's
version, read states. R_B holds no conversation row. R_A keeps the conversation like any server today (Commits 90 days, Welcomes 30 days); R_B's items follow "Relay queues".
Rejected: a sequencer that moves between relays or a vote between them (the state resolution the 2026-10-08 decision rejected), and storing the conversation on both relays (two truths for epochs).
When the home is gone: unreachable directly and through the member's relay for 7 days, or
absent from the creator's newest card and not answering. Any member's app may then create a
successor in its own relay's direct space with the same members and
predecessor: { hostServerId, conversationId }; apps show both as one timeline from the cache and
archive. Of two successors the one with the smaller id is canonical (ids are UUID v7, so this is
the earlier) and members leave the other. Members learn a conversation is a successor from
Conversation.predecessor. The creator's app also makes successors when its person leaves the relay.
The request inbox #
R_B files a DmInvite by Bob's effective DM setting, three layers where each may only
tighten the one above:
| Layer | Senders outside Bob's workspaces on R_B | Members of a workspace R_B hosts with Bob |
|---|---|---|
| R_B's operator | RIM_DM_INBOUND: open, requests (default), partners (the servers RIM_DM_PARTNERS lists, and R_B), none |
none |
| Each of Bob's workspaces on R_B | A floor: none, requests or closed |
A default: direct or requests |
| Bob | requests or none, within the floors |
Per workspace direct or requests |
A member of a shared workspace is delivered when they are its Owner or a Moderator, Bob's
contact, or when Bob's choice (else the workspace's default) for one shared workspace is
direct; otherwise the invite is a request. Anyone else is refused (blocked) when the
conversation's host is a server RIM_DM_INBOUND does not admit, contacts included; then a contact
or a principal Bob accepted before is delivered, and otherwise the strictest of the operator's
floor (none refuses senders on R_B too), the workspaces' floors and Bob's choice makes it a
request or a refusal (blocked). A contact is someone Bob added, accepted, or messaged first:
the home records each other member of a DM as its creator's contact.
A request is a relay_dm_request row: the sender's card, the conversation, and up to 3 held items of it,
the DmInvite first (later ones dropped until accepted; the app reads them from the host). The
app verifies the held DmInvite like any relay item to bind the conversation's space to its home
before it reads or leaves it; ignoring or blocking keeps it with the request until the row
expires. Only DmRequestReceived is pushed. An invite from a contact or a principal accepted
before is recorded as a row accepted at once (decided_at = created_at), not listed by
dmRequests and not counted by the limits, so every conversation R_B delivers for has a row and
items of any other conversation are refused.
When the home and the recipient's relay are one server (Bob's relay hosts the conversation, and
its creator is someone else, from another relay or not), the home files its own DmInvite for
Bob through the same rule instead of delivering it; the creator's server is the home, or the home
relay of a creator from another server. A DM between members of a workspace R_B hosts is filed
the same way, its DmInvite naming the workspace as workspace_id. A DM the rule refuses for a
member of the home is refused when it is created (FORBIDDEN). The invite and the conversation's events are
held as above, and until Bob accepts, the conversation is left out of his inboxConversations,
its events out of his workspaceEvents, and its notifications, mentions and unread messages out
of his myNotifications, myUnreadNotificationCount, unreadCounts and email
digest; ignoring keeps it out, and so does blocking its creator.
Once accepted it is an ordinary conversation of the space he uses directly.
| Action | Effect |
|---|---|
acceptDmRequest(id) |
Held items released; later items flow; the sender becomes a contact |
ignoreDmRequest(id) |
Held items deleted but the DmInvite; later items of that conversation dropped; row expires in 30 days |
blockPrincipal(principalId) |
Pending requests from it ignored; its DmInvites refused (blocked) and its claims refused; the app leaves the conversation through the home |
| Nothing | Expires after 30 days |
The app shows the card's name (verified against the sender's key log) and a fingerprint of the
principal id, base32(first 10 bytes of SHA-256("rim.principal-fingerprint.v1\0" || principal id))
in four groups of four, the same on every device, and may process the Welcome to read the held
messages before deciding. It may accept on its own a request from someone the person's settings
for a shared workspace deliver directly (myDmSettings, checked on host-signed member lists),
such as a member of a workspace hosted on another server, which R_B cannot see.
Limits: 100 pending per recipient, 20 per sender principal per day per relay
(RIM_RELAY_DM_REQUESTS_PER_DAY); reportDmRequest(id, reason) reports abuse: it files a
relay_abuse_report row naming the reporter, the sender, the host and the request (audit
relay.dm_request_reported), and a pending request is ignored.
Under RIM_DM_INBOUND=partners R_B answers GET /s2s/v1/principals/<id> only to the servers
RIM_DM_PARTNERS lists (the S2S signature names the caller), under none to no server: 404
as for an unknown principal. POST /s2s/v1/key-packages/claim from those servers is refused with
403. A server where the principal holds an active subscription to a workspace is answered
either way. Outbound, RIM_DM_OUTBOUND (allowed, partners, none) limits the servers whose
people R_B's users may add to a conversation of its direct space and claim key packages from, and
the relays R_B sends direct-space events to.
Remote workspace members (7.4b) #
Joining #
app ─────────────────────► relay R ─────────────────────────► host H
remoteServer(origin) GET /.well-known/rim-server (pin H from the invite)
remoteJoinChallenge(H, origin, t) POST /s2s/v1/join/challenge → {challengeId, nonce, expiresAt, workspaceId}
sign a login proof (audience = H's server id) and a delegation
joinRemoteWorkspace(input) POST /s2s/v1/join → {workspaceId, userId, role, streamHeads}
relayStream ◄─── POST /s2s/v1/deliver (events)
- The invite link is read like a device-link code: whitespace removed, at most 400 characters,
split at the first four
:after the scheme (the origin keeps its colons). The app pins H. - The challenge is today's
keyLoginChallenge(120 s, single use) over S2S:POST /s2s/v1/join/challenge {"inviteToken"}→{"challengeId","nonce","expiresAt","workspaceId"}. The invite link carries no workspace id, and the delegation must name one, so H answers the workspace the invite names, checked aspreviewInviteTokenchecks it and not used (404 not_foundfor an unknown invite,403 forbiddenfor one revoked, used up or expired);workspaceIdis null without an invite. The device signs aloginstatement withaudience= H's server id, so the proof is good only at H and the relay never vouches for the person. - The device signs a delegation. R sends H
{"inviteToken","keyLog":[…],"devices":[{"certificate","revocation"}],"card","proof","delegation"}. - H verifies log, certificates, proof (claiming the challenge) and invite token (the rules of
joinServerByInvite, bans included), creates or updates the remote user, adds the membership with the invite's role, stores devices and card, accepts the delegation, and answers{"workspaceId","userId","role","streamHeads":{"w":17,"u:rimp1_…":0}}. R records the subscription; the app loads the space through the forward path. The join carries nomlsActive, so H marks devices it did not know inactive for encryption; R pushes the full identity (POST /identity) right after the join, as it does for any host holding the person.
A current member (an earlier join, or an existing local account on H) sends inviteToken: null.
Delegation #
{"v":2,"type":"delegation","principalId":"rimp1_…","deviceId":"<uuid>","relay":"rims1_…","relayKeyHash":"…",
"host":"rims1_…","workspaceId":"<uuid>","scope":"workspace","nonce":"<16 bytes>","issuedAt":"…","expiresAt":"…"}
Signed by the device key over rim.delegation.v1\0 || payload: "relay relay may receive this
workspace's events for me and forward my device-signed requests to host until expiresAt". It
never lets the relay act as the person; writes still need device signatures. nonce is 16 random
bytes, unpadded base64url; the app issues delegations for exactly 24 hours from a whole second.
A host accepts it (on join or POST /s2s/v1/subscriptions) when the signature verifies under a
non-revoked device of the principal (an unknown device is fetched once from the presenting relay's
principals); host is its own id and relay the presenting peer; relayKeyHash names a key of
that relay's log with no signer: recovery rotation after it (an ordinary rotation keeps
delegations, a recovery kills them); the principal is a current, unbanned member of workspaceId;
scope is workspace; expiresAt − issuedAt ≤ 24 h and expiresAt is ahead. It keeps it until
min(expiresAt, accepted_at + 24 h) by its own clock, so a device clock cannot extend it. One row
per (workspace, device, relay); a newer one replaces it unless the active row runs longer, so a
relay replaying an older delegation cannot shorten or reset the one it holds.
Subscriptions. POST /s2s/v1/subscriptions {"delegations":[…]} (at most 64) →
{"subscriptions":[{"workspaceId","status","expiresAt"}]}, one entry per delegation in order,
status active or refused with a reason (invalid_delegation, bad_signature,
wrong_host, wrong_relay, relay_key, expired, unknown_device, revoked_device,
not_member, banned, limited); workspaceId is null for a delegation that cannot be read.
renewDelegations answers these reasons and two of the relay's own, and logs the detail behind
them. host_unreachable: the host could not be reached, failed, rate-limited the relay or gave an
unreadable answer, and the relay keeps the subscription as it was. host_refused: the host refused
the presentation as a whole, or refused a delegation for a reason outside this list.
A relay's subscription for (user, workspace) lives while one of the user's devices has an active
delegation there.
POST /s2s/v1/subscriptions/cancel {"subscriptions":[{"workspaceId","principalId"}]} (at most 64)
ends the presenting relay's delegations for them → {"cancelled":<n>}.
Leaving, a kick or a ban ends the user's delegations for the workspace after MemberLeft is sent;
the departed member gets that MemberLeft on their u: stream, being no member of w any more.
join, join/challenge, subscriptions and subscriptions/cancel are workspace host endpoints: a
server running only relay answers 503 role_disabled. presence and revocations are served by
every role (a relay is the home of DMs). A relay delivers items of a workspace a user holds an
active subscription to straight to the queue; the request inbox is for everything else.
Renewal. An online device signs fresh delegations when the current ones have less than 6 hours
left and sends them with renewDelegations; the relay presents them. When every device has been
offline 24 hours the subscription lapses, the host stops queuing for it, and on return the app
renews and catches up from the change feed and streamHeads. Short delegations bound a hidden
revocation, as the relay decision requires. Rejected: a delegation for the whole host (a stolen
relay key would reach every workspace there), and long-lived delegations with explicit revocation
only (a hidden revocation would never end).
Presence. The relay reports POST /s2s/v1/presence {"updates":[{"principalId","online"}]}
(at most 64) → {"applied":<n>} to the hosts of the person's subscriptions when their first
relayStream opens, and once the last has been closed for 30 s with none reopened. The host
applies it in every workspace where the relay holds an active delegation of the person; a remote
member's presence does not time out between reports.
Members, roles, bans, timeouts, private channels, archive state, the plugin registry and bundles, search, notifications, the change feed and MLS sequencing stay the host's, checked by the same services for remote members. Bundles and attachments come through the forward path, so the web app reads them same-origin from its relay.
Revocation propagation #
t = 0 app → relay: revokeDeviceKey(revocation) the relay commits it locally
t = 0 relay → every host and DM home holding the person: POST /s2s/v1/revocations
retries at 10 s, 30 s, 1 m, 2 m, 5 m, then every 15 m until 24 h
t ≤ 2 m app polls deviceRevocationStatus(deviceId), or gets RevocationAcked on relayStream
t = 2 m app → each host without a verified ack, directly: applyRemoteRevocation(revocation)
t = 24 h every delegation the revoked device signed has expired regardless
POST /s2s/v1/revocations {"principalId","revocation":{"payload","signature"}} → {"ack":{…}}:
{"v":2,"type":"revocation-ack","host":"rims1_…","keySeq":0,"principalId":"rimp1_…","deviceId":"<uuid>","revocationSha256":"<base64url>","appliedAt":"…"}
The host verifies the revocation against its copy of the log (identity key, or the device itself),
marks the device revoked, ends its delegations and direct sessions, raises MlsMembershipChanged
where it is a group device, and acknowledges; a repeat gets the same acknowledgement (appliedAt
is when the device was first revoked there). A device it does not know is fetched once from the
presenting relay; one it still does not know is 404 not_found, and the relay stops sending to
that host (GAVE_UP). The relay verifies each acknowledgement under the host's pinned key before
recording it, and pushes
{"type":"RevocationAcked","deviceId","hostServerId","ack":{"payload","signature"}} on
relayStream to the person's devices online.
applyRemoteRevocation is the same on the host's GraphQL, without a session and exempt from CSRF
(it acts only on a fresh identity or device signature, like loginWithKey), at most 20 attempts
per IP per 15 minutes (RATE_LIMITED past them). The app verifies each
acknowledgement against the pinned key and its revocation's hash, and names any host it could not
reach: "<host> has not confirmed; the device keeps access to <workspace> until <time>", the
delegation's expiry at most. The relay stops forwarding anything the revoked device signs as soon as
it commits.
Direct connection #
The fallback when the relay is unreachable; desktop only in v1 (a browser cannot hold cookies for another origin).
- When. After 60 seconds of failed relay requests (network errors, 5xx, bad host signatures on forwarded responses), or when "messages may be missing" persists after reconciling. The app asks once per host ("Connect directly to <host>? It will see your IP address") and remembers it.
- How. It fetches the host's discovery document directly and checks it against the pin, then
calls
keyLoginChallengeandloginWithKeyon the host withaudience= the host's server id. The host finds the remote user and device (refreshing its copy from the person's relays first, carrying on if they are down) and issues a device-bound session valid 24 hours for a remote user (the host waits up to 10 seconds for the relays). The app then uses the host's/apiand/api/wsas a local client would, under a banner "Connected directly to <host>". The account mutations a remote user may not run are refused on that session too (FORBIDDEN). - Meanwhile. Events from the host's
workspaceEventscarry noseq, so the app holds the host'sRelayItems unapplied and unacknowledged while connected directly. - Back. It probes
relayInfoevery 60 seconds; after two successes it renews delegations, asks the host directly for thestreamHeadsof the streams it knows there and takes them as their baseline, runs the held items and readsrelayStreamon from its last item, deduplicating by(host, stream, seq), and closes the direct connections.
Switching relays #
signupWithKeyon the new relay R2 with the same identity: the key log, this device's certificate and a proof whoseaudienceis R2's server id, sent directly (desktop) with a session kept outside the webview;loginWithKeyinstead when an earlier attempt created it.- Publish a card (version + 1) with
relays: [R2, R1]to both; direct-space hosts deliver to both. - Sign a delegation for R2 for every subscribed workspace, including those R1 hosts (a delegation
may name a local member too), and
renewDelegationson R2. - Drain R1's
relayStream; nothing new reaches R1 that does not also reach R2. - If R1 will go away, list the conversations homed in R1's direct space; once the app runs on R2 it creates their successors there (a successor is created by the app's session on its own relay), with R1's direct space as a space elsewhere.
- Publish
relays: [R2]and callleaveRelayon R1, which cancels its subscriptions at every host and deletes the person's queue, contacts and requests there (theuserrow stays: it may still be a member of spaces R1 hosts).
History is on the devices and the drive, keys on the devices, spaces on their hosts: nothing is lost.
Public relay (7.4c) #
The public relay (relay.rim.chat is its working name; the domain is not registered yet, and
every mention of it here and in the runbook is a placeholder) runs the stock image with
RIM_ROLE=relay. In pow mode signupWithKey takes a registration token (instance invite or
workspace invite, as in invite-only mode) or proofOfWork, not both: an 8-byte nonce
(unpadded base64url) such that
SHA-256("rim.pow.v1\0" || relay server id (ASCII) || "\0" || inception payload bytes || nonce)
starts with powBits zero bits (the descriptor's, from RIM_REGISTRATION_POW_BITS; 24 to start,
about 16.7 million hashes on average). It binds the work to this relay and this principal; the
server checks one hash before any signature; the open-signup per-IP limit applies too, and each
such signup is audited as relay.registration_pow. Password signups take a token, as in
invite-only mode; registrationMode answers POW. The check is rim_s2s::pow. Rejected: a
server-issued puzzle (a round trip and stored state for no gain) and memory-hard functions (no
WebCrypto support).
| Quota per local user | Public relay | Setting |
|---|---|---|
| Queued bytes | 64 MiB | RIM_RELAY_QUEUE_BYTES_PER_USER |
| Card / key packages / devices | 1 card of 128 KiB / 100 unclaimed per device / 32 | RIM_RELAY_CARD_MAX_BYTES, RIM_RELAY_KEY_PACKAGES_PER_DEVICE, RIM_RELAY_DEVICES_PER_USER |
| Forwarded requests | 120 per device per minute, 50,000 per user per day | RIM_S2S_DEVICE_RATE_PER_MIN, RIM_RELAY_FORWARDS_PER_USER_PER_DAY |
| DM requests sent | 20 per sender principal per day | RIM_RELAY_DM_REQUESTS_PER_DAY |
Each is enforced where it applies: the card at publishProfileCard, key packages at
uploadMlsKeyPackages, devices at addDeviceKey (all VALIDATION), forwarded requests at
/api/remote/… (429). The DM request limit counts on both sides: the relay of the recipients
refuses a sender principal's invites past it (limited), and the sender's relay refuses a local
user's invites past it to people on other servers who are not their contacts (RATE_LIMITED).
Counters are in memory, per node.
Abuse hooks (S5-6), each audited: rim_server principal block <principalId> --reason …,
principal unblock, principal list (relay_principal_block): the account's sessions are ended
and its pending DM requests ignored for every recipient; its logins, signups and enrollments are
refused (FORBIDDEN), deliveries to it (unknown_principal) and its invites (blocked), key
package claims for and by it, its identity (404) and forwarded requests it signs (403).
rim_server peer block|unblock <serverId> ("Pinning a peer"; like RIM_S2S_DENIED_PEERS without
a restart). reportDmRequest and the abuse address (rim_server abuse-reports add) feed
relay_abuse_report, listed by rim_server abuse-reports list [--open] and closed by
abuse-reports resolve <id>.
Runbook (docs/operations/relay-public.md, S5-6): who holds the server recovery key and where,
yearly server-key rotate and recover on compromise, database and key file backups with a restore
drill, quotas and how to change them, monitoring (queue bytes, outbox depth per peer, deliver
failures, 401/409/429 rates, PoW signups), incident response for spam waves, a hostile peer, a
stolen server key and a database leak, and the abuse policy text.
Compose (deploy/compose.yml with the deploy/compose.relay.yml override, and
deploy/README.md): rim-server with RIM_ROLE=relay, RIM_PUBLIC_BASE_URL=https://<RIM_DOMAIN>,
RIM_REGISTRATION_POW_BITS=24, RIM_RELAY_QUEUE_BYTES_PER_USER=67108864 and the other quotas
above, RIM_TRUST_FORWARDED_HEADERS=true, RIM_FORCE_SECURE_COOKIES=true, RIM_DATA_DIR=/data on a
volume (the audit log too); a migration step before it; Postgres 17 on its own volume, on a
network with no way out; Caddy as the TLS proxy passing /api, /api/ws, /s2s/v1 and
/.well-known/rim-server unchanged with bodies up to 32 MiB and setting X-Forwarded-For
itself.
Migration of single-server deployments #
The migrations are additive and the key is created at first boot. With RIM_PUBLIC_BASE_URL unset
the discovery document has endpoints.s2s null and nothing else changes. Existing accounts are
local, existing workspaces hosted here; the desktop app's card already lists this server as its
relay. Old invite links keep working; createInvite also answers the rim-invite:1: form when S2S
is on. down migrations refuse to run while remote users, delegations or queued items exist.
Storage #
One additive migration per PR (sea migrate generate …).
-- S5-1 add_server_identity
CREATE TABLE server_key_log_entry (seq integer PRIMARY KEY CHECK (seq >= 0),
entry_type text NOT NULL CHECK (entry_type IN ('inception', 'rotate')),
payload text NOT NULL, signature text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE server_descriptor (id smallint PRIMARY KEY CHECK (id = 1), version integer NOT NULL,
payload text NOT NULL, signature text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE peer_server (server_id text PRIMARY KEY, origin text NOT NULL,
head_seq integer NOT NULL, head_key text NOT NULL, previous_key text NULL,
rotated_at timestamptz NULL, rotation_signer text NULL CHECK (rotation_signer IN ('server', 'recovery')),
descriptor_version integer NOT NULL, descriptor_payload text NOT NULL, descriptor_signature text NOT NULL,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'forked', 'blocked')),
first_seen_at timestamptz NOT NULL DEFAULT now(), fetched_at timestamptz NOT NULL,
last_request_at timestamptz NULL);
CREATE INDEX idx_peer_server_origin ON peer_server (origin);
CREATE TABLE peer_key_log_entry (server_id text NOT NULL REFERENCES peer_server ON DELETE CASCADE,
seq integer NOT NULL, payload text NOT NULL, signature text NOT NULL, PRIMARY KEY (server_id, seq));
CREATE TABLE s2s_nonce (peer_server_id text NOT NULL, nonce text NOT NULL,
expires_at timestamptz NOT NULL, PRIMARY KEY (peer_server_id, nonce));
CREATE INDEX idx_s2s_nonce_expires ON s2s_nonce (expires_at);
-- S5-2 add_relay_delivery
ALTER TABLE server ADD COLUMN kind text NOT NULL DEFAULT 'workspace',
ADD CONSTRAINT chk_server_kind CHECK (kind IN ('workspace', 'direct'));
CREATE UNIQUE INDEX uq_server_direct ON server (kind) WHERE kind = 'direct';
ALTER TABLE conversation ADD COLUMN predecessor_host text NULL,
ADD COLUMN predecessor_conversation_id uuid NULL;
ALTER TABLE device_key ADD COLUMN request_highest_seq bigint NULL;
CREATE TABLE device_request_seen (device_key_id uuid NOT NULL REFERENCES device_key ON DELETE CASCADE,
seq bigint NOT NULL, expires_at timestamptz NOT NULL, PRIMARY KEY (device_key_id, seq));
CREATE TABLE host_event_stream (workspace_id uuid NOT NULL REFERENCES server ON DELETE CASCADE,
stream text NOT NULL, seq bigint NOT NULL, PRIMARY KEY (workspace_id, stream));
CREATE TABLE s2s_outbox (id bigserial PRIMARY KEY, peer_server_id text NOT NULL, item text NOT NULL,
size_bytes integer NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE INDEX idx_s2s_outbox_peer ON s2s_outbox (peer_server_id, id);
CREATE TABLE s2s_peer_delivery (peer_server_id text PRIMARY KEY, delivered_through bigint NOT NULL DEFAULT 0,
failures integer NOT NULL DEFAULT 0, next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_success_at timestamptz NULL);
CREATE TABLE s2s_peer_inbound (peer_server_id text PRIMARY KEY, received_through bigint NOT NULL DEFAULT 0);
CREATE TABLE relay_envelope (id bigserial PRIMARY KEY, host_server_id text NOT NULL,
workspace_id uuid NOT NULL, stream text NOT NULL, seq bigint NULL, payload text NOT NULL,
signature text NOT NULL, received_at timestamptz NOT NULL DEFAULT now());
CREATE UNIQUE INDEX uq_relay_envelope_seq ON relay_envelope (host_server_id, workspace_id, stream, seq)
WHERE seq IS NOT NULL;
CREATE TABLE relay_dm_request (id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES "user" ON DELETE CASCADE, from_principal_id text NOT NULL,
card_payload text NOT NULL, card_signature text NOT NULL, host_server_id text NOT NULL,
conversation_id uuid NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'ignored')),
created_at timestamptz NOT NULL DEFAULT now(), decided_at timestamptz NULL,
expires_at timestamptz NOT NULL, UNIQUE (user_id, host_server_id, conversation_id));
CREATE TABLE relay_item (id bigserial PRIMARY KEY, user_id uuid NOT NULL REFERENCES "user" ON DELETE CASCADE,
envelope_id bigint NOT NULL REFERENCES relay_envelope ON DELETE CASCADE,
device_key_id uuid NULL REFERENCES device_key ON DELETE CASCADE, -- one device only
request_id uuid NULL REFERENCES relay_dm_request ON DELETE CASCADE, -- held by a request
size_bytes integer NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL);
CREATE INDEX idx_relay_item_user ON relay_item (user_id, id);
CREATE INDEX idx_relay_item_expires ON relay_item (expires_at);
CREATE TABLE relay_device_cursor (device_key_id uuid PRIMARY KEY REFERENCES device_key ON DELETE CASCADE,
acked_item_id bigint NOT NULL, updated_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE relay_contact (user_id uuid NOT NULL REFERENCES "user" ON DELETE CASCADE,
principal_id text NOT NULL, relay_urls text[] NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, principal_id));
CREATE TABLE relay_block (user_id uuid NOT NULL REFERENCES "user" ON DELETE CASCADE,
principal_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, principal_id));
CREATE TABLE remote_identity_cache (principal_id text PRIMARY KEY, served_by text NOT NULL,
identity text NOT NULL, fetched_at timestamptz NOT NULL); -- the principals JSON
-- S5-4 add_remote_workspaces
CREATE TABLE host_delegation (id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES server ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES "user" ON DELETE CASCADE,
device_key_id uuid NOT NULL REFERENCES device_key ON DELETE CASCADE,
relay_server_id text NOT NULL, payload text NOT NULL, signature text NOT NULL,
accepted_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL,
ended_at timestamptz NULL, UNIQUE (workspace_id, device_key_id, relay_server_id));
CREATE INDEX idx_host_delegation_active ON host_delegation (workspace_id, relay_server_id, user_id)
WHERE ended_at IS NULL;
CREATE TABLE relay_subscription (id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES "user" ON DELETE CASCADE,
host_server_id text NOT NULL, workspace_id uuid NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'lapsed', 'ended')), expires_at timestamptz NULL,
updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (user_id, host_server_id, workspace_id));
CREATE TABLE relay_revocation_fanout (device_key_id uuid NOT NULL REFERENCES device_key ON DELETE CASCADE,
host_server_id text NOT NULL, status text NOT NULL CHECK (status IN ('pending', 'acked', 'gave_up')),
attempts integer NOT NULL DEFAULT 0, next_attempt_at timestamptz NOT NULL,
ack_payload text NULL, ack_signature text NULL, PRIMARY KEY (device_key_id, host_server_id));
-- S5-6 add_relay_abuse; down refuses while either table has rows
CREATE TABLE relay_principal_block (principal_id text PRIMARY KEY, reason text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE relay_abuse_report (id uuid PRIMARY KEY,
reporter_user_id uuid NULL REFERENCES "user" ON DELETE SET NULL,
subject_principal_id text NULL, subject_server_id text NULL,
request_id uuid NULL REFERENCES relay_dm_request ON DELETE SET NULL, reason text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz NULL);
A per-minute cleanup (like the key challenge cleanup) deletes expired nonces, device_request_seen
rows, relay items and orphaned envelopes, expired requests and outbox rows older than 7 days, and
ends expired delegations. Code follows the layers: repository traits in rim_core (s2s, relay),
S2sService, RelayService and HostService traits in rim_services, implementations in
rim_infra_services, handlers in rim_server (the s2s module and remote_handlers.rs), wired in
app.rs.
GraphQL #
Additive, on the client's relay unless marked "host"; new objects append to the last groups.
type RelayInfo { serverId: String! roles: [String!]! directSpaceId: ID! }
type RemoteServer { serverId: String! origin: String! discovery: String! } # the raw document
type RemotePrincipalDevice { deviceId: ID! certificate: SignedStatement! revocation: SignedStatement
mlsActive: Boolean! }
type RemoteIdentity { principalId: String! keyLog: [SignedStatement!]! devices: [RemotePrincipalDevice!]!
card: SignedStatement servedBy: String! }
type Contact { principalId: String! relayUrls: [String!]! addedAt: DateTime! }
enum DmRequestStatus { PENDING ACCEPTED IGNORED }
type RelayItem { id: ID! hostServerId: String! workspaceId: ID! envelope: SignedStatement! }
type RelayItemPage { items: [RelayItem!]! hasMore: Boolean! }
type DmRequest { id: ID! fromPrincipalId: String! card: SignedStatement! hostServerId: String!
conversationId: ID! heldItems: [RelayItem!]! status: DmRequestStatus!
createdAt: DateTime! expiresAt: DateTime! }
type RemoteSubscription { hostServerId: String! workspaceId: ID! status: String! expiresAt: DateTime
reason: String } # status refused, from renewDelegations
enum RevocationFanoutStatus { PENDING ACKED GAVE_UP }
type RevocationFanout { hostServerId: String! status: RevocationFanoutStatus! ack: SignedStatement
attempts: Int! }
type RemoteJoinChallenge { challengeId: ID! nonce: String! expiresAt: DateTime! workspaceId: ID }
type RemoteJoinResult { hostServerId: String! workspaceId: ID! userId: ID! role: String! }
type StreamHead { stream: String! seq: String! } # host; decimal
type ConversationPredecessor { hostServerId: String! conversationId: ID! }
type Conversation { predecessor: ConversationPredecessor } # additive field
input RemoteMemberInput { principalId: String! relayUrls: [String!]! }
input PredecessorInput { hostServerId: String! conversationId: ID! }
input CreateConversationInput { remoteMembers: [RemoteMemberInput!] predecessor: PredecessorInput } # additive fields
input SignupWithKeyInput { proofOfWork: String } # additive field (S5-6)
enum RegistrationModeGql { POW } # additive value (S5-6)
input JoinRemoteWorkspaceInput { hostServerId: String! hostOrigin: String! inviteToken: String
proof: SignedStatementInput! delegation: SignedStatementInput! }
type Query {
relayInfo: RelayInfo!
remoteServer(origin: String!): RemoteServer!
remoteIdentity(principalId: String!, relayUrl: String): RemoteIdentity!
contacts: [Contact!]!
dmRequests(status: DmRequestStatus): [DmRequest!]!
relayItems(after: ID, limit: Int): RelayItemPage! # limit 1..500, default 200
remoteSubscriptions: [RemoteSubscription!]!
deviceRevocationStatus(deviceId: ID!): [RevocationFanout!]!
streamHeads(serverId: ID!, streams: [String!]!): [StreamHead!]! # host
}
type Mutation {
addContact(principalId: String!, relayUrls: [String!]!): Contact!
removeContact(principalId: String!): Boolean!
acceptDmRequest(id: ID!): DmRequest!
ignoreDmRequest(id: ID!): DmRequest!
blockPrincipal(principalId: String!): Boolean!
unblockPrincipal(principalId: String!): Boolean!
reportDmRequest(id: ID!, reason: String!): Boolean!
ackRelayItems(upTo: ID!): Boolean!
remoteJoinChallenge(hostServerId: String!, hostOrigin: String!, inviteToken: String): RemoteJoinChallenge!
joinRemoteWorkspace(input: JoinRemoteWorkspaceInput!): RemoteJoinResult!
renewDelegations(delegations: [SignedStatementInput!]!): [RemoteSubscription!]!
leaveRelay: Boolean!
applyRemoteRevocation(revocation: SignedStatementInput!): SignedStatement! # host; no session, CSRF-exempt
}
type Subscription { relayStream(after: ID): String! } # JSON strings, as workspaceEvents
Every new mutation but applyRemoteRevocation needs the CSRF header. A peer that cannot be reached
is the new code UPSTREAM_UNAVAILABLE with hostServerId, the peer's server id. When only its
origin is known (a relay URL from a card or contact that could not be pinned), hostServerId is
null and origin carries that origin. A peer's S2S error keeps its meaning (FORBIDDEN,
NOT_FOUND, CONFLICT, RATE_LIMITED); RATE_LIMITED's retryAfterSeconds is the peer's
Retry-After (at most a day), or 60 when the peer sent none.
Limits and audit #
| What | Limit |
|---|---|
| S2S body / forward body / discovery fetch | 8 MiB / 32 MiB / 64 KiB |
| Clock window; nonce retention | created ≤ 300 s ahead, expires − created ≤ 300; 600 s |
| Deliver; outbox | 256 items and 8 MiB per request; 7 days |
| Relay queue | 30 days; 256 MiB per user (64 MiB on the public relay) |
| Delegations | ≤ 24 h; 64 per request; 10,000 active per relay per host (a renewal replacing an active one does not count) |
| Device requests | seq window 5 minutes; 120 per device per minute |
| DM requests | 100 pending per recipient; 20 per sender per day; 3 held items; 30 days |
| Stranger claims | Last-resort packages only; 20 per claimer per day |
| Remote identity | Log ≤ 256 entries; card 128 KiB; cache 24 h |
| Remote user's direct session | 24 h |
Rate limits are the in-memory per-node limiters; nonces, seq windows and cursors are in the
database, so correctness never depends on one node. Audit (ids only):
s2s.peer_pinned, s2s.peer_rotated, s2s.peer_forked, s2s.peer_blocked,
s2s.peer_unblocked, host.remote_join, host.delegation_accepted, host.revocation_applied,
relay.principal_blocked (scope user for blockPrincipal, server for the operator's),
relay.principal_unblocked, relay.blocked_principal_refused, relay.dm_request_reported,
relay.abuse_report_filed, relay.abuse_report_resolved, relay.registration_pow,
server_key.created, server_key.rotated, server_key.recovered, s2s.peer_reset. Events with an acting user and workspace go to command_audit_log; the server
key and peer events have neither (its columns are NOT NULL), so they go to the audit log stream
(target = "audit", as key logins do). Logs carry server, principal and row ids, never payloads. Metrics: outbox depth and oldest age per peer, deliver failures, relay
queue bytes, S2S responses by status, signature failures by reason.
Threat model #
| Adversary | Can | Cannot |
|---|---|---|
| Hostile relay | Read plaintext channel events and DM metadata it queues; drop, delay, reorder; refuse to forward; see which spaces its users use and when | Forge a write (no device key); forge or alter host events or responses (pinned host key); replay (seq, nonces); use a delegation for another workspace, host or relay; hide a revocation past 24 hours once the app reaches the host directly; read DMs (MLS) |
| Hostile host | What a server can for its own spaces today, plus remote members' principals, devices and activity there | Sign as a device; use a delegation at another host; read DMs homed elsewhere; see a remote member's IP outside a direct connection |
| Hostile peer | Send signed junk up to its limits; claim last-resort packages and send DM requests as a stranger | Impersonate another server (self-certifying id); replay; push events for spaces it does not host (envelopes name their host; relays accept only subscribed spaces); pass an allow-list |
| Stolen server key | Sign as that server until recovery: receive delegated events as a relay, sign false events to its own members as a host | Sign as users; extend delegations; outlive a signer: recovery rotation (peers drop the old key at once, delegations naming it die) |
| Stolen device | Whatever that device can do, through any relay or directly | Act after a host applies the revocation |
| Network attacker | See TLS metadata between servers | Alter bodies (digest, signature), replay, substitute a server at a pinned id |
| DNS or TLS compromise of a relay URL | Answer as that URL for card-based delivery and first-use pins | Present a pinned server id; take delegations (they name the id) |
Metadata. A relay learns its users' spaces, contacts, DM partners, activity and the size and timing of all it queues; a host learns its remote members' principals, devices, activity and relays. This is the exposure the relay decision accepts. Signatures are classical Ed25519 until server and device keys move to hybrid.
Where the existing code forces a compromise #
| Today | Compromise | PR |
|---|---|---|
conversation.server_id is non-null; DMs live in a workspace |
The direct space: one server row of kind direct per relay, guests only |
S5-2 |
| GraphQL authenticates by session cookie and CSRF | A second, device-signed path on /s2s/v1/forward/…; the cookie path unchanged |
S5-2, S5-4 |
One WebSocket, workspaceEvents(workspaceId) per space |
relayStream on the same socket for everything hosted elsewhere |
S5-2 |
| Events go through a lossy broadcast (capacity 1024; lagging receivers skip) | A separate, non-lossy observer feeds the outbox; events still fire after commit, so a crash in between leaves a gap the client reconciles | S5-2 |
Visibility rules live in rim_api/src/subscription.rs |
Moved to rim_services for the outbox writer |
S5-2 |
Audiences are lowercased host[:port] |
The server id is accepted too, compared exactly | S5-1 |
Usernames are unique across user |
Remote rows get r_…; local signup refuses the prefix |
S5-1 |
principalDevices answers only principals sharing a workspace |
Also remote users, and remoteIdentity |
S5-2 |
| Per-IP rate limits | Keyed by relay and principal for forwarded requests | S5-2 |
| Sessions last 30 days | 24 hours for a remote user's direct session | S5-4 |
Test plan #
- Unit.
rim_s2s: RFC 9421 B.2.6 and appendix A byte for byte; each refusal of "Verifying a request" (missing or reordered component,expirestoo far,createdahead, wrongalgortag, digest mismatch, only another label); a response for another nonce fails.rim_identity: the new statements' vectors and refusals (v1 form, unknown fields, delegation over 24 h, wronghost, another body hash). Services with mock repositories: pinning (equal, extension, fork, recovery, grace), theseqwindow, delegation rules, stranger rules, request transitions, queue retention and overflow, the revocation schedule, the role table. - db-tests. One nonce inserted twice concurrently, one wins; per-stream
sequnder concurrency without gaps or repeats; outbox order per peer;received_throughidempotency; items deleted only once every device acked; cascades; each migration up and down, and the refusingdowns. - e2e with two servers (
rim_api/tests/s2s_e2e.rs,e2e-tests): two Actix servers in one process with two databases,RIM_S2S_ALLOW_HTTPandRIM_S2S_ALLOW_PRIVATE_ADDRESSES. Flows: pinning and rotation; an encrypted DM across relays from creation to edit and read state (rim_mlsfixtures); a DM request accepted, ignored, blocked; a remote join; channel traffic both ways with host events verified by a test client; a gap and its reconciliation; revocation with acknowledgement; the direct login; a relay switch; the allow-list;relayandhostrefusals; replayed and tampered requests. A two-server compose test in CI when CI is back (ROADMAP.md). - Clients (S5-3, S5-5). Appendix A in the Tauri module and Vitest; pinning from an invite (current key, rotated key, fork); event verification and gaps; contacts by link and QR; a DM across relays on two desktops; the request inbox; a remote join; the direct fallback and its notice; a relay switch without losing a message.
Deferred #
- Hybrid Ed25519 + ML-DSA-44 server keys and S2S signatures (tagged keys,
algfrom the key and caps are ready). - Matrix interoperability; encrypted private channels; relay discovery by DNS; multi-relay fan-out for reads; moving a workspace to another host.
- Proof-of-work on first contact (a
powfield reserved forDmInvite); mobile push through relays; server ids in profile cards;https://invite and contact links. - Deleting queued messages once the drive has them (open decision).
Appendix A: known-answer vectors #
Computed with Python cryptography 50.0.2; server id and request signature cross-checked with
Node 26 crypto. Keys are SigningKey::from_bytes([seed; 32]). The device is
key-identity-v1.md appendix B's (seed 20, rimp1_txDLbnipi5i3kCJtJwBOUixzv_MY8OnU0mcy--Tyje0,
device 00000000-0000-0000-0000-000000000001). Workspace 0199d1a0-0000-7000-8000-000000000002,
conversation …000000000003, message …000000000004. JSON has fields in the order shown and no
spaces. Never use these values for real keys.
A.1 Server inceptions #
| Item | Relay (seed 30, recovery 130) | Host (seed 40, recovery 140) |
|---|---|---|
| Server key | ed25519:rNsOKXQ_DMuGhtChBMuW4Fq-_sFTh2XnWVhp99yMSao |
ed25519:6S62BU_pvGgqG887dZ9lqzikz72BxNHzNC5Myc3tiws |
| Recovery key | ed25519:-gE0epkeBK4PGm2B2yml6k7rWWX1d6ye-0TnsfL7sCg |
ed25519:_L44YyQXu1uHXUmjwCJwYzkXxgk_0B67Wf9kFuN6_gw |
| Server id | rims1_T5LLFxkrobgyvlf3WVxuvLlfqYSZAssRFgmvCfI2R-c |
rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw |
| Key hash | 8UGftl4tBoxZjUo1UP_-zwUlobQJPwj5kZ0NC1oaAaM |
qkSWxxJEL_11l8_dTCGhHc_1Q1pAc2pMirMBGnz7388 |
prev for seq 1 |
dMszB0YhzGkVSf4B-3elynzitpFsvVYojOEW8hXiNKg |
oUvCbfqOS1bUTL5gAtene_faqghv8PaPgrRDIJpXLOM |
| Inception signature | ed25519:_oQe2QcbA37vNosXrUVHFf8iIbnaesyNMmPtj827Wx5Q6vt77tyaOYR6JA9e3Z2FjHDRs0uR0XQEj9TMwoIODA |
ed25519:Dll_XFAqcMn9Pejfpge4oAGiEH16dAEu8KLjLjSEDPd8UFk3-tmRgzINC2G89uyREl1aDYI5bA-O8XOF7oftAg |
Host inception (the relay's has its own keys in the same fields):
{"v":2,"type":"server-inception","seq":0,"serverKey":"ed25519:6S62BU_pvGgqG887dZ9lqzikz72BxNHzNC5Myc3tiws","recoveryKey":"ed25519:_L44YyQXu1uHXUmjwCJwYzkXxgk_0B67Wf9kFuN6_gw","createdAt":"2026-10-09T00:00:00Z"}
A.2 Host descriptor #
{"v":2,"type":"server-descriptor","serverId":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","keySeq":0,"version":1,"roles":["host","relay"],"endpoints":{"graphql":"https://host.example/api","websocket":"wss://host.example/api/ws","s2s":"https://host.example/s2s/v1"},"s2sVersions":[1],"mlsCipherSuites":[79],"limits":{"maxBodyBytes":8388608,"clockSkewSeconds":300,"maxDeliverItems":256},"registration":{"mode":"invite","powBits":null},"issuedAt":"2026-10-09T00:00:00Z"}
ed25519:ZdBDMoUEFhLQMnX7dK_r8xUPa0dNoSHOZqfAHelFegW0PUpluRUX84gzBzp2TtMKku19aCrf4wMXjbemA8b_Bw
A.3 Delegation #
Device seed 20, to the relay of A.1, for the host of A.1; nonce bytes 70..7f.
{"v":2,"type":"delegation","principalId":"rimp1_txDLbnipi5i3kCJtJwBOUixzv_MY8OnU0mcy--Tyje0","deviceId":"00000000-0000-0000-0000-000000000001","relay":"rims1_T5LLFxkrobgyvlf3WVxuvLlfqYSZAssRFgmvCfI2R-c","relayKeyHash":"8UGftl4tBoxZjUo1UP_-zwUlobQJPwj5kZ0NC1oaAaM","host":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","workspaceId":"0199d1a0-0000-7000-8000-000000000002","scope":"workspace","nonce":"cHFyc3R1dnd4eXp7fH1-fw","issuedAt":"2026-10-09T00:00:00Z","expiresAt":"2026-10-10T00:00:00Z"}
ed25519:aT8j0zeSdSqWySreIIeRokrjVoYuO-3z1k4IcsFN898M2x_in0B9UlaL5hCUAdw-gCisynSQi3b51QQiZdHlCQ
A.4 Signed S2S request and response #
The relay presents A.3: POST https://host.example/s2s/v1/subscriptions,
Rim-Origin: https://relay.example, created 1791504000 (2026-10-09T00:00:00Z), nonce bytes
60..6f, body (685 bytes) {"delegations":[{"payload":<A.3 payload as a JSON string>,"signature":<A.3 signature>}]}.
"@method": POST
"@target-uri": https://host.example/s2s/v1/subscriptions
"content-digest": sha-256=:NLGvVDM7TsvDtemcqNyHnp4tVM9bTzAjg1MbseDLrOc=:
"content-type": application/json
"rim-origin": https://relay.example
"@signature-params": ("@method" "@target-uri" "content-digest" "content-type" "rim-origin");created=1791504000;expires=1791504300;nonce="YGFiY2RlZmdoaWprbG1ubw";keyid="rims1_T5LLFxkrobgyvlf3WVxuvLlfqYSZAssRFgmvCfI2R-c#0";alg="ed25519";tag="rim-s2s-v1"
That is the signature base (lines joined by \n, no trailing newline). Signature-Input is sig1=
followed by the @signature-params value, and:
Signature: sig1=:N1zyrK5mP9qxsSJOKAriCI38lQHL2H8FiKprKMqOay1+XFgIaldQw7J5zILaG6/hWcw6ESUC4HGY/bJ1O+QMDg==:
The host's response: status 200, created 1791504001, Rim-Request-Nonce: YGFiY2RlZmdoaWprbG1ubw,
body {"subscriptions":[{"workspaceId":"0199d1a0-0000-7000-8000-000000000002","status":"active","expiresAt":"2026-10-10T00:00:00Z"}]};
its signature base, then its Signature field:
"@status": 200
"content-digest": sha-256=:BBeCXY4Vnpnqik7XJ8TNUUC/Y392lt0Zhhcz9uwijB8=:
"content-type": application/json
"rim-request-nonce": YGFiY2RlZmdoaWprbG1ubw
"@method";req: POST
"@target-uri";req: https://host.example/s2s/v1/subscriptions
"content-digest";req: sha-256=:NLGvVDM7TsvDtemcqNyHnp4tVM9bTzAjg1MbseDLrOc=:
"@signature-params": ("@status" "content-digest" "content-type" "rim-request-nonce" "@method";req "@target-uri";req "content-digest";req);created=1791504001;keyid="rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw#0";alg="ed25519";tag="rim-s2s-v1"
Signature: sig1=:rGIqqwBn079G9OciB/u4fSIro9i6S0P97QNxFSo8UxPvXJwywOS4z5NrmPIYNnr2MKxK2dSzsBl0eU97wQFGAw==:
A.5 Host event #
{"v":2,"type":"host-event","host":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","keySeq":0,"workspaceId":"0199d1a0-0000-7000-8000-000000000002","stream":"c:0199d1a0-0000-7000-8000-000000000003","seq":42,"event":{"type":"MessageDeleted","workspace_id":"0199d1a0-0000-7000-8000-000000000002","conversation_id":"0199d1a0-0000-7000-8000-000000000003","conversation_type":"Channel","message_id":"0199d1a0-0000-7000-8000-000000000004","thread_id":null},"issuedAt":"2026-10-09T00:00:00Z"}
ed25519:CPSwWGrkjbauuToACdrAuJL4L6mv1XpGTPy_XKjkymGwzqx6pyW-j6V52HkhpPlXx82tXRbQTI-GsH9N4N-zBg
A.6 Device-signed write and host-signed response #
GraphQL body (117 bytes):
{"query":"mutation($id:ID!){deleteMessage(messageId:$id)}","variables":{"id":"0199d1a0-0000-7000-8000-000000000004"}}
{"v":2,"type":"device-request","principalId":"rimp1_txDLbnipi5i3kCJtJwBOUixzv_MY8OnU0mcy--Tyje0","deviceId":"00000000-0000-0000-0000-000000000001","host":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","method":"POST","path":"/api","seq":1791504000000000,"bodySha256":"a2PtmXdQU8dMqDduw8cQymbwEmFEilShfZfDDArTcwU","issuedAt":"2026-10-09T00:00:00Z"}
ed25519:IoWInJdss89VdGgCEnWvLRCAHZ--5mrIzRiDV0thfCwLOVOgyEZzaGG7CGBfgKqnnJiJvzHMxaCZYFiEMiSbCg
Rim-Device-Request (base64url of that payload):
eyJ2IjoyLCJ0eXBlIjoiZGV2aWNlLXJlcXVlc3QiLCJwcmluY2lwYWxJZCI6InJpbXAxX3R4RExibmlwaTVpM2tDSnRKd0JPVWl4enZfTVk4T25VMG1jeS0tVHlqZTAiLCJkZXZpY2VJZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMSIsImhvc3QiOiJyaW1zMV9PRlBETlhUVjFzcGxDY0tjZ3oxdldZajZNZy1sYlVvbGxIUXdYVGtZRUJ3IiwibWV0aG9kIjoiUE9TVCIsInBhdGgiOiIvYXBpIiwic2VxIjoxNzkxNTA0MDAwMDAwMDAwLCJib2R5U2hhMjU2IjoiYTJQdG1YZFFVOGRNcURkdXc4Y1F5bWJ3RW1GRWlsU2hmWmZEREFyVGN3VSIsImlzc3VlZEF0IjoiMjAyNi0xMC0wOVQwMDowMDowMFoifQ
The host's answer to body {"data":{"deleteMessage":true}}:
{"v":2,"type":"host-response","host":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","keySeq":0,"requestSha256":"USMHYKhT_TDTgBiA_mrWvOz3-RZLlohDKUQREkVxMCY","status":200,"bodySha256":"6lPwX_33fnLq0QTkKknG-F-tK81icmIjQKnN0eS8JA0"}
ed25519:f-vulCqj3GgD3dI4zNAeCMs1JGyDwkzbkcLhk0uXXMz4NCJ-az1ZVKWxgpbqNMWTzMhSKtnFPhwMHmATk69zDg
A.7 Revocation and acknowledgement #
The device revokes itself ("signer":"device"); the host acknowledges.
{"v":2,"type":"device-revoke","principalId":"rimp1_txDLbnipi5i3kCJtJwBOUixzv_MY8OnU0mcy--Tyje0","deviceId":"00000000-0000-0000-0000-000000000001","signer":"device","issuedAt":"2026-10-09T00:00:00Z"}
ed25519:kCcxKVwjdFvYSC3xx3yHWgO5gWwwq9wpC8bzQs1NPn9bKhAUJSBdLawiI1bWkNDDOdeW_x7AEm0fafPf38D3Bw
{"v":2,"type":"revocation-ack","host":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","keySeq":0,"principalId":"rimp1_txDLbnipi5i3kCJtJwBOUixzv_MY8OnU0mcy--Tyje0","deviceId":"00000000-0000-0000-0000-000000000001","revocationSha256":"VYCvacXofNMIe3q3-owfpopw8oLdXAOc6KvNKsEtis4","appliedAt":"2026-10-09T00:00:00Z"}
ed25519:waKbDNMuQ460M8gX_H1NKzAMDXF3x4P8hbHoJFPr-pSduvfvQSbXvOcRmK5VcF5F9-sICQmF-pBnACBu9Z-mDg
A.8 Proof-of-work #
At the relay of A.1, for key-identity-v1.md appendix B's inception payload, with test difficulty
powBits 16: the smallest working counter is 37283, nonce AAAAAAAAkaM (8 bytes big-endian), hash
00002f63d137749fda27201ccf9d282a4ed846287d49c55fea89a47617e78ed6.
Filled by S5-1 #
RFC 9421 appendix B.2.6 through rim_s2s (rim_s2s/src/vectors.rs): the RFC's
test-key-ed25519 (seed: the last 32 bytes of its PKCS#8 key), the components "date" "@method" "@path" "@authority" "content-type" "content-length" with created=1618884473 and
keyid="test-key-ed25519" serialize to the RFC's Signature-Input, the crate builds the RFC's
signature base, and signing it gives the RFC's
sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:.
Rotation. The host of A.1 rotates from seed 40 to seed 41 (signer: server, recovery key
unchanged, createdAt 2026-10-10T00:00:00Z), signed by the seed 40 key:
{"v":2,"type":"server-rotate","serverId":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","seq":1,"prev":"oUvCbfqOS1bUTL5gAtene_faqghv8PaPgrRDIJpXLOM","serverKey":"ed25519:-kg0FH9uaQw2k-_2EzYEZAPNiuKhTzGzxAc1hWkjlWU","recoveryKey":"ed25519:_L44YyQXu1uHXUmjwCJwYzkXxgk_0B67Wf9kFuN6_gw","signer":"server","createdAt":"2026-10-10T00:00:00Z"}
ed25519:cysmxfZAsbKJKvFGkftqHCqAzySvf4MMD9PTimeNavN_Oi91Zt-lfcvCswkb08SnVDrS-iMWK1WT1n7iY_fZAA
| Item | Value |
|---|---|
| Key hash of the seed 41 key | fvKh1r_va0lqRvlpGojY2V1JzJMQeSESlNpO4OXw6Ak |
prev for seq 2 |
sU02HLfXJNvkpkHn28DO3JdBxQpmTr3Dl4y94zIY9hU |
The descriptor of A.2 re-signed by the seed 41 key at keySeq 1, version 2, issuedAt
2026-10-10T00:00:00Z (the A.2 descriptor no longer verifies against this log):
{"v":2,"type":"server-descriptor","serverId":"rims1_OFPDNXTV1splCcKcgz1vWYj6Mg-lbUollHQwXTkYEBw","keySeq":1,"version":2,"roles":["host","relay"],"endpoints":{"graphql":"https://host.example/api","websocket":"wss://host.example/api/ws","s2s":"https://host.example/s2s/v1"},"s2sVersions":[1],"mlsCipherSuites":[79],"limits":{"maxBodyBytes":8388608,"clockSkewSeconds":300,"maxDeliverItems":256},"registration":{"mode":"invite","powBits":null},"issuedAt":"2026-10-10T00:00:00Z"}
ed25519:Yuy9VdOS78e8fL4sMDT9PHmBkZcuzeJEvLlfdD8dRhDjJeJiivU4FcatDrlwEKaB3l9Beado7Qi6_33D7UN0AA
Both computed by rim_s2s and cross-checked with Python cryptography 50.0.2. Every value of
A.1 to A.8 reproduces byte for byte in rim_s2s's tests; A.8's nonce also signs up through
signupWithKey at the A.1 relay in rim_infra_services's tests.
Without RIM_PUBLIC_BASE_URL the descriptor's graphql and websocket endpoints use
http://localhost:<RIM_SERVER_PORT>; s2s is null.