docs

Concepts

A link joins exactly two devices of one app. It is defined by two values both devices hold:

Value Size Secret Purpose
Link key 32 bytes Yes The root secret. Every key and identifier is derived from it.
Link id 16 bytes No Names the link. Used as the HKDF salt, so two links never share a mailbox even if a key repeated. A UUID works.

The link key must come from a cryptographically secure random generator or a key agreement. It never leaves the two devices. The link id may be any 16 bytes the devices agree on; a random UUID (version 4) is the simplest choice.

Primary and companion

The two ends of a link have roles:

  • The primary holds the source of truth, usually the phone.
  • The companion mirrors it: a laptop, tablet, watch or browser.

The protocol only uses the roles to name the two directions. What they mean in your app is up to you, but each device must know which role it plays, and the two devices must play different roles.

Directions

Each link has two one-way channels:

Direction Wire value Path segment Written by Read by
Primary to companion 1 p2c Primary Companion
Companion to primary 2 c2p Companion Primary

Each direction has its own encryption key and its own sequence numbers. Neither side can replay the other's envelopes back to it, because an envelope sealed for p2c will not open as c2p.

In the SDKs you choose the direction a Link sends; it reads the reverse.

SDK Primary Companion
Swift .primaryToCompanion .companionToPrimary
Kotlin Direction.P2C Direction.C2P
JavaScript Direction.P2C Direction.C2P
.NET Direction.P2C Direction.C2P

Mailbox

The mailbox is where a link's envelopes wait on the relay. Its id is 16 bytes derived from the link key, written in URLs as 32 lowercase hex characters. It holds both directions.

The first device to present a valid bearer token claims the mailbox. From then on, only that token can use it. Both devices derive the same token, so both can use the mailbox; anyone else gets 401 or 403.

A mailbox holds at most 32 MiB and 64 unread envelopes per direction. It is erased when a device unpairs, or automatically 30 days after the last write or claim.

Bearer token

A 32-byte value derived from the link key, sent as Authorization: Bearer <token> in unpadded base64url (43 characters). The relay stores only its SHA-256, so a copy of the relay's storage does not let anyone use a mailbox.

App id

Every relay request path starts with an app id: a UUIDv7 that the relay operator registers. It is not a secret; it ships inside your app. It keeps unknown clients off the relay and keeps each app's mailboxes apart: the same mailbox id under two app ids is two different mailboxes.

You generate an app id once per app with npm run app-id in the relay repository, then register it in the relay's RELAY_APPS setting. See Configuration.

Envelope

One sealed message:

Text
version (1) ‖ direction (1) ‖ sequence (8) ‖ nonce (12) ‖ ciphertext (n) ‖ tag (16)

The first 10 bytes are the clear header. The relay checks that the header matches the URL it was stored under, but it cannot open the envelope. Plaintext is at most 1 MiB; overhead is 38 bytes. See Envelope format.

Sequence number

A 64-bit unsigned number, starting at 1 and strictly increasing within one direction. The sender picks the next number; the relay refuses a number that is not above the last one stored (409). The receiver refuses a number at or below the last one it accepted, which defeats replays.

Persist the receiver's last received sequence between launches. A receiver that forgets it would accept an old envelope again. The SDKs expose it as lastReceived and accept it back when you construct a Link.

Watch

A WebSocket per direction that announces new sequence numbers as they are stored. It carries no data. A device that gets a notification fetches as usual. See Watch WebSocket.

Prune

After opening what it fetched, the receiver tells the relay to drop everything up to the highest sequence it saw. The relay keeps nothing it does not have to.

View this page on GitHub Suggest a change