Skip to content

Latest commit

 

History

History
332 lines (224 loc) · 73.3 KB

Security.md

File metadata and controls

332 lines (224 loc) · 73.3 KB

Security

Back to README

Security is not bolted on. It is structural, and every layer enforces its own guarantees.

Contents

Transport Encryption (DTLS)

All MCSession instances, both the primary session and the dedicated relay session, are created with MCEncryptionPreference.required. Apple's MultipeerConnectivity framework performs a DTLS handshake before any application data is exchanged.

Pool Code Authentication

Pool codes are never included in Bonjour discovery metadata. A joiner sends the code as part of the invitation context. The host validates it before calling the invitation handler. This prevents passive eavesdroppers from learning the code by observing Bonjour traffic.

Host Approval

With auto-accept off, a code only earns a question: the host answers every joiner on its primary and relay advertisers alike, and an approved invitation lands in the session the joiner invited (primary or relay). The host needs this on both, because it stops its primary advertiser at the first connection, so every later joiner and every rejoiner reaches it through the relay advertiser. If that session is gone, the pool has filled, or the device was blocked while the host decided, the approval fails closed.

A non-host member relaying the pool holds the code it joined with, advertises that a code is needed and refuses a wrong or missing one. It admits nobody by itself. A joiner who passes those checks is held, and the member sends a join_approval control message with a random request ID up its one upstream link (through other members if it joined through one). The host applies its own block list, pool size and auto-accept setting, or asks its user with the member named on the request. The answer comes back down the same links, signed by the host (see The Pool Key). A member acts on an approval only from its upstream link, for a request it made, and with the host's signature on it. An upstream member can hold an answer back; it cannot make one up. A refusal needs no signature, and from a member that could not reach the host cannot have one, since only the host holds the key: it gives that link nothing it did not already have, because holding the answer back refuses the joiner too, 50 s later. No answer within 50 s, no way to reach the host, or the member leaving the pool all refuse the joiner. A request is passed up or heard only from a link the device let in, so a neighbour in a session cannot spend a member's slots on the host.

join_approval is a new PoolMessageType, not a system event. Older peers fail to decode the message as a whole and drop it with a log line. An unknown system event would decode on 1.5.0 and show as chat text. Consequence: a host too old to answer refuses everyone who comes through a newer member; a member too old to ask still admits on the code alone, which only a member's upgrade can change.

The same channel carries who is in the pool: a member reports a person arriving through it (believed once, and only for someone the host approved through that link) or leaving (believed only from the link that holds them), and the host announces departures to everyone, saying in its signature when one is a removal. A row in the host's list says who a request came through: the name the host knows for that link, not what the request claims, and a chain's first hop only if the host knows that person is behind that link.

A modified client can still admit people to its own relay whatever the host said: it holds the session they join. Pool Chat's content stays end-to-end encrypted either way, and those people cannot pass for anyone else. They are not hidden, or not for long: an MCSession connects all its peers to each other, and a member advertises on the session it joined on, so someone it lets in can appear directly on the host and on others in that session. Appearing that way lifts no removal, because only this device's own admission or the host's word does, and the host signs a roster entry only for someone it admitted and has not removed since, checked again as the entry goes out, after the wait for a new link to settle. Someone removed who reappears that way is not listed, on the host or on any member: every member keeps the removal from the host's signed word, drops what comes from that person or their link and sends them nothing, until a newer roster entry or the host's approval says otherwise. That includes a member who joined after the removal: as the roster reaches a newcomer, so do the signed departures for those the host has removed, at most the 32 newest, dated then, so a later entry still lifts them. Until they arrive, about 3 s after joining, or for anyone past those 32, such a link is listed as someone nobody admitted. MultipeerConnectivity promises no way of forcing one peer off a session. A member asks with cancelConnectPeer, which closed the link on simulators, but if a link stays, nothing passes over it. Someone nobody admitted is listed on the devices they appear on, so that a host can see them and remove them, and nobody is told of them on the host's word.

The links are not a strict tree. Everyone in one MCSession is a peer of everyone else in it. Who joined through whom is therefore kept apart from who happens to be a neighbour: a person is reported up by whoever let them in, a member passes things for them along that way, and a neighbour's link carries only that neighbour's own word. The host's word is taken from the upstream link, and from a peer named after the host only once it has signed a challenge with the pool key (see below). A pool without a key has no such exception.

Names are not unique. Everyone's ID is their name, and anyone can pick any name, so every link is also known by its MCPeerID. The first peer under a name holds it. A device hands a name over only to someone it just admitted (an invitation names the very peer; an approval through a member lets the next new peer under that name take it, until that person is reported as arrived). Any other peer under a held name counts for nothing: its arrival, its departure and everything it sends are ignored, nothing is sent to it, and the holder is not affected. It takes the name up when the holder has gone, which is also how a rejoiner whose old link outlived them is recognised on a device that did not admit them. A host that admits someone under a name held by a peer it never admitted takes the name from that peer, and that peer's holding it is no reason to refuse them.

A name on the screen is what someone typed. A profile's name is its sender's word, and so is the name a relay passes on, so anyone can pick the name of someone else in the pool, and their emoji. So names are compared as they read: case, accents, width, spacing, characters that show nothing (including Unicode's default-ignorable ones, braille and Hangul blanks and the object placeholder), common lookalikes in Cyrillic, Greek, Cherokee and Lisu, Latin letters such as ı, ł and small capitals, and anything after a bracket make no difference (PeerNames). A word mixing Latin with another script is treated like a collision in what shows next to the name, whatever its letters are and whoever else is here, which catches lookalikes on no list, other than Latin alongside Chinese, Japanese or Korean, which is ordinary writing. A join request says which it is: that the name reads like someone else's only when someone's does, and that it mixes alphabets when it does. In a nearby pool, someone whose profile name reads differently from their ID shows both, as "Alice (Mallory)": the ID is the name the host counts them in under, and the host keeps those unique. A profile name that reads like their ID shows nothing more, unless another ID reads like it too or it mixes scripts, when it shows a short hash. In a remote pool, anyone sharing a name with someone else shows the start of their key, and so do those they share it with. A name stays taken for the rest of the pool once someone has shown it, so that waiting for someone to leave is no way round this. A remote pool stays the same pool through relay reconnects and relaunches, which is what its names are kept by. At most 256 names are kept, and the oldest not in use go first. This device's own name counts. A host keeps the names of a relay pool through its own relaunch, in the Keychain, for one pool at a time, and deletes them when it leaves or closes that pool. A member's list starts again when its app does.

How a name arrives and shows. Whatever way a name arrives, it is put on one line first: no control characters, nothing that turns text round, blanks collapsed, at most 128 characters. So a name of 10 MB costs nothing later, and "\u{202E}ecilA" shows as what it spells. What tells someone apart is drawn separately, and a long name gives way to it. A list row, a call and a message read the name when shown, by the sender the transport named: a person who becomes a collision later, and an open chat with them, take the tag then. A message from someone who has left, or from another pool, and a past chat, keep the name stored with them. A game takes the same name by the authenticated sender, and shows no invitation from someone this device does not list. Players the list does not have stay as the game's host named them.

What this does not settle. An honest cost: someone who changes their profile name during a nearby pool shows both names until the next pool, and someone writing one word in Latin and another script shows a tag. A nearby ID is only what someone picked, and lookalikes in one script outside that list are not caught. A key's start can be ground for, given effort. The safety code is what says who someone is.

A link is not the person it is named after. On a member of a pool with a key, a link it did not let in, named after someone the host's roster vouches for, may be that person or anyone else. So that person is reached and heard the way the host's word about them came, whatever such a link does. Nothing meant for them goes on it, and its loss removes nobody and forgets no key: they go when the host signs that they did. It is still heard for itself, like any neighbour, because it may well be them. What it says under that name can do no more than what any link could say for someone whose place is not known: Pool Chat's content stays end-to-end encrypted, a different key is refused or warned about, and game moves and profiles are as unauthenticated as they are from any bridging member. A profile that gets here before word of its owner is kept (at most 32) until they are listed, under the same checks as one that comes after. The host's roster entry fills in a profile only for someone listed with none, and their own word replaces it.

Who is heard in a chat. In a nearby pool, chat and key exchanges count only from someone the host counts in. On a host, that is someone it let in, or approved through a member. On a member of a pool with a key, it is the host, the link it joined through, someone on the host's signed roster, or someone it let in on the host's word. An envelope's introduction is held to the same list. Otherwise a name nobody here has met, or someone who has left, would be a first contact for whoever said it, and the real person's key would then be the one that raises the warning. The message still goes on to everyone else, each of whom judges it. A person's key can overtake the roster entry for them on another path, so on a member what comes from someone not counted in waits for the host's word and then goes on in the order it came: at most 32 messages and 1 MB from one name, and 16 names, for about 30 s, as long as what came still fits the link it came on. A new name always gets its place: the oldest name goes, of the same link's own once that link has 8, so that filling the list refuses nobody and one link cannot push out what another is waiting on. A signed departure drops what is waiting, and so does the host's approval of that person: they have not connected yet, so whoever said it was someone else. A host has nothing to wait for, as it counts people in before they can say anything, and drops what names anyone else. An envelope is not held: its origin tries again only when it next sends. What a hold loses comes back another way. Being listed starts a key exchange with anyone this device has no key for, including someone already listed through a link of their own, and the other side answers the same key again, at most once in about 30 s, in case it was its own that was lost. A peer on 1.5.x does not answer a repeat, so a pair with one on it stays as it was until a rejoin. A pool whose host signs nothing, which is a host older than this, has no roster to hold anyone to and goes on as it did. Remote pools have the relay's word on each frame instead.

The Pool Key

Every member bridges messages for the others, and a message names its own sender, so a name on a message proves nothing. In a local pool the host signs what carries authority with a pool key, and members check against the key they pinned when they joined.

  • The key is the host's EnvelopeSigningKeys session key: Ed25519, random, in memory only, replaced when a pool ends. It was already all of those things, so nothing new is stored. Statements start with their own length-prefixed label (STEALTH_POOL_HOST_STATEMENT_V1), so a signature made for an envelope or a key binding can never verify as one, or the reverse. The host-signed grant of a remote pool could not serve: it signs with a long-lived identity key that exists only on remote pools, and a local advert must not carry anything that lasts.
  • Advertised in discovery as poolKey (members re-advertise it). A joiner pins what the advert it joined through states.
  • Signed: join decisions (request ID, joiner's name, pool ID, approved), removals (target, pool ID, the join they end, time), pool close (pool ID), every roster entry including its isHost claim and its time, departures (person, pool ID, time, and whether the host removed them), and answers to link challenges (nonce, who asked, pool ID). A roster entry cannot make anyone but the session's host the host. A removal names the nonce of the join it ends, so that showing it again after that person has rejoined means nothing. Nothing goes on a link in its first 2.5 s, while it settles, so a removal said in that time is said again when the link can carry it, signed and dated anew and naming the same join, and only then is the link closed. A member passing it to someone on such a link does the same. Only a signed close says the pool closed: a member that loses its host, or anyone else with no way left into the pool, hears that the connection was lost, as a host in the background and a host that closed are different things.
  • Statements about a person are dated and ordered. A member takes a roster entry, a removal of someone else or a departure only if it is within 10 minutes of its own clock and newer than anything it has from the host about that person. The host's times only go up, whatever its clock does. An old entry therefore cannot lift a newer removal, and an old removal or departure cannot act on someone who has come back.
  • Link challenge. A joiner through a member can also find itself connected to the host directly, and a host's targeted message then arrives on that link. Anyone can be named after the host, so such a link is sent a random nonce as soon as it can carry one, and nothing bound for the host goes on it, broadcast or targeted, until it answers: all of that goes up the way this member joined. What claims to be the host's word on it is held, at most 32 messages and 1 MB per peer for 10 s, so that one peer named after the host cannot use up what the real one needs. Only a signature with the pool key over that nonce and this member's name releases it, and that peer then speaks for the host and carries its traffic. A host answers only someone it admitted, a few times a link. A failed or absent answer drops what was held and changes nothing else, which is what happens when a host does not count a link formed after the person was reported as arrived. Such a link leaving ends nothing: the pool is reached through the upstream link.
  • Fallback. A pool whose host advertises a key refuses unsigned host messages. A pool without one, which is a host or a relaying member older than this, falls back to link checks alone and works as it did.
  • Link checks are the second layer, everywhere: a device drops a message claiming to be from itself, a member drops one claiming to be from the host unless it arrives on its upstream link (compared by MCPeerID, since names are not unique) or a link that proved the pool key, and anyone drops one claiming to be from someone whose place is known and is elsewhere. A removal needs a target, and a host refuses any. Profiles therefore only arrive from their owner's own path.
  • Remote pools have no pool key. The relay authenticates which peer sent a frame and peer IDs are identity keys, so a removal, pool close or roster entry counts only if the relay says the host sent it. There are no hops either: mesh relaying is off, each frame comes straight from the peer the relay names, and everyone's messages name themselves. So a message naming anyone but the peer the relay names is dropped, whatever its type, before Pool Chat or a game sees it. A hostile relay can still name whoever it likes as the sender of a frame, and a chat key has no signature from the identity key that the ID is. So a relay can send a key exchange naming someone, under that person's own ID, with a key of its own. In a pool with no outer layer it needs nothing else. In a protected pool the frame has to be sealed, so it needs a member holding the transport secret. The device warns of a changed key if it already has one for that person this session. If not, this is the first key it hears for them, and it opens the stored conversation with that ID (see Stored Conversations), which is then shown for a key the relay holds. Comparing the safety code exposes this. Signing the chat key with the identity key would end it, and is a change to the protocol.

What this does not cover.

  • First contact. Whoever a joiner joins through states the key it pins. A first-hop member can substitute its own key, and then speaks for "the host" to that joiner and everyone behind them. A fake advert copying a pool's ID can do the same to someone who chooses it from the list. Nothing can prevent this without a prior shared secret (a pool code does not help: the member holds it too). It needs the joiner to reach the pool through that member, it fails against a joiner who reaches the host directly, and Pool Chat's safety code still exposes the member if the people concerned compare it. A join proof names its first hop only by name, because an invitation is the first message and leaves no round trip for the hop to add anything of its own: a fake advert under the same name, pool key and scope also receives a proof it can show the real hop within 10 minutes, as the joiner, though it still needs the host's approval.
  • Upstream members. A member on the path can refuse to pass messages on. It cannot say someone left: in a pool with a key, a departure from above takes effect only with the host's signature and a newer time. Word of a departure going up from a member is unsigned, as only the host holds the key, and the host believes it only from the link that holds that person. In a pool with no key, left is the link's word as it always was.
  • A replay inside the window. A statement newer than anything a member has about that person, and less than 10 minutes old, is taken. An upstream member can therefore show a member that just joined a roster entry from a few minutes ago for someone who has since left, until the host's next word about that person.
  • Someone on the roster, spoken for by someone else. The roster says who is in the pool and not what key they hold, as adding a key to it would change what is signed and older members could not check it. So a message or a key exchange naming a person on the roster, sent by someone below the same bridge, can reach a device that has no link of its own to that person: a device cannot place them, and the link check lets it through. Their key is then replaced, with the warning any unexplained change gets, or is first contact if this device has met nobody under that name yet, with the real person's key raising the warning. The sender gains no way into what was sent to the real person, and the safety code exposes it.
  • Someone an older member let in. A member too old to ask the host admits on the code alone and reports nobody. The host does not count that person in, so nothing they say is heard as chat by anyone but that member and anyone they let in, until that member updates. That was so in practice before as well: nobody else listed them, so nobody answered their key.

Device Keys

MultipeerConnectivity names are what the user typed, so blocking by name is undone by renaming. A joiner therefore presents an Ed25519 device key with a signature (additive JoinContext fields; older hosts ignore them). Block lists store key:<SHA-256 prefix of the key>.

  • Keys per pool. The key is derived with HKDF from one random seed in the Keychain (local_device_seed), with the scope and pool key its host advertises. The scope (idScope) is random and new for every pool, and so is the key, so nothing nearby listeners hear links a host's pools to each other or follows a rename, and two pools see unrelated keys for one device, including two pools of one host. A joiner sends no key to an advert with no pool key, or one whose scope is not a pool's own: a remote: scope would otherwise ask this device for its key in someone's remote pool. An advert that copies a scope but states another pool key gets an unrelated key.
  • What that costs. A block on a device key stops at that pool. The same device coming back to the same pool, after leaving or relaunching, is recognised and refused. A new pool from the same host does not recognise it, though it still needs the code and the host's approval. A manual block also refuses a joiner showing no key under the name recorded, for as long as that entry lasts. An entry under a device key can match nothing once its pool has ended, since no key outlasts its pool: such entries are removed when a host's pool ends, when a member's pool is closed by its host, and at every launch, and the list shows no dead rows. Entries under a name stay.
  • Proofs. The signature covers the joiner's name, the pool ID, the pool key, the first hop it is given to (the host when direct, the member when forwarded), the time and a random nonce. A host or member checks all of them: the first hop against the path the proof actually took (for a forwarded request, the link, someone it knows is behind that link, or a neighbour in a session, never someone outside the pool), the time within 10 minutes either way, and a cache of proofs already seen. A proof seen from another presenter is a replay and refused. A proof that is stale, replayed or made for someone else persists nothing. Anyone more than 10 minutes from the verifier's clock cannot prove a key and is refused, and that works both ways: see Clocks. The cache keeps 1024 proofs: a flood past that forgets the oldest first, and needs an invitation per entry.
  • Matching. An entry under the proved key refuses. A joiner proving a key answers for that key alone: an entry under a name never refuses it, so nobody can lock a person out by using their name. A joiner showing no key is refused by an entry under its name (blocks stored before device keys are name-keyed) and by the name a host's manual block on a key recorded, so dropping the key is no way around that block. Note what follows for blocks made on 1.4.x, all of which are name-keyed: they now refuse only joiners too old to show a key.
  • Wrong codes. Five from a device that just proved its key block that key. Attempts that prove nothing (no key, or a proof shown again) persist nothing and count only towards the global limiter below, so a stranger's guesses cannot cost anyone else anything.
  • A name coming back. The host keeps which key each ID was let in under for the whole pool, at most the newest 256. A row under a name someone was let in under, showing another key or none, says so, as that ID is free once they have left and anyone can ask under it. With auto-accept on there is no row to mark. In a pool with no device keys there is nothing to compare.
  • Names in use. A person's ID in a pool is their name, so the host refuses a joiner under its own name, or under a member's name held with a key unless the joiner proves that key (the same key is a rejoin; showing none is no way round this). A joiner showing no key under a name held without one cannot be told from a rejoin and is let through, on the link that holds them: through another member, it is refused, because announcing someone as arrived and then gone would take them off the list. A member refuses a joiner named after itself or its upstream. A default profile name carries a random number and is saved at the first pool, so two people who never set a name do not collide.
  • Request rows. A request reuses a row the host is looking at only if it has the same name, key and way in (the same link, and primary or relay on a direct invitation), and the row's contents follow the request. Anyone else gets their own row. A joiner showing no key cannot be told from another showing none under that name on the same way in. At most 16 people can be waiting on a direct invitation at once, on top of the same number through members: anyone else is declined, and someone with a row keeps it. A row ends a little before its joiner's own invitation times out (28 s of 30 on a direct invitation, 58 of 60 on a relay), so that it cannot be answered once nobody is waiting. When someone asks again, the earlier invitation is dropped, not declined, and when a row expires nothing is declined either. MultipeerConnectivity cannot tell which invitation an answer is for: the joiner's new browser numbers them from 0 again, and a decline sent to someone who has gone is held for their next connection, so it would fail their next attempt at once while the row stayed. What remains: a joiner who left early and whose row the host then declined or blocked has their next attempt fail once. A row accepted after its joiner has gone connects nothing; the host is told within 30 s (90 s through a member), and nothing is kept about them. An Accept is not held the way a decline is: on simulators, with the joiner leaving before the Accept and asking again 8 s and 48 s later on the same advertiser, and once on the other one, the session gave up on the old Accept and the new attempt waited for its own answer. That is observed framework behaviour on one OS version, not a promise; if it ever changed, the joiner would be listed on the host and not vouched for, and the host could see and remove them.
  • Removals while a window is closed. The manager acts on a removal or pool close itself, so a window that was closed says what happened when it next opens.
  • Join requests while a window is closed. The manager keeps the request, whatever windows are open, and a window that opens shows what is waiting. With the app in front, the pool window opens or comes forward. With the app in the background or locked, a local notification says someone asked to join, without saying who, and the window comes up when the app is next in front and unlocked. Asking costs nothing and can be repeated, so the screen and the sound are taken at most once in 20 s, and a new notification replaces the last; whatever is still waiting at the end of that time is surfaced then. Remote pools are not covered: their requests still reach only a view model that is alive.
  • Limits. A modified client or a reinstall can present a new key. This raises the bar from typing a new name; it cannot prove one physical device. A member present in two pools does not see one key for a person across them, but it can of course recognise a display name.

Chat key continuity. Pool Chat's X25519 keys stay per session and per launch, for forward secrecy. They used to make every rejoin report "security code changed", which trains people to ignore the warning. A key exchange now also carries the device key (remote pools: scoped to the pool), a counter, and a signature over the session's X25519 key and that counter. A changed key signed by the device key that signed the old one, with a higher counter, is reported as nothing, unless the user had verified that person: the verification covered the old key, so it ends and the chat says so. A proof ranking no higher than the newest from that device key, for another chat key, is an old key being shown again while that person is here: it is refused, the key in use stays, and the chat says so once. The counter is the time unless that would not be the highest yet, and how far it has gone is kept in the Keychain (one value for the device, so that the Keychain holds no list of pools), so a clock moved back does not lower it. A failed read of that value writes nothing, so it cannot lower it either, and the next read that works picks it up. What each pool sees is that value plus an offset derived from the device's secret and the pool's scope, so that members of two pools cannot line up what one device sends. Should it fall back anyway, with that value lost, the refusal ends when that person is known to have left: their next key is taken with the warning any unexplained change gets. Someone showing an old key then gains that warning and a key they cannot use, which the next proper one replaces. Any other change warns and is accepted: a changed key with no proof, or with a proof that does not hold, replaces the old one. The private chat with that person always says so, once for each run of changes with nothing said between them. The group hears of it only if the person changed key while still here or had given a proof before, and then once a person, a session: a forged key is about what the whole group reads from them, while a peer too old to prove anything brings a new key every time it comes back, which would otherwise be noise in a mixed pool. Someone who left and comes back with another key and no proof, having never shown one, therefore warns in the private chat alone. The list of chats is read again at most every 300 ms through a burst of such changes, as that reads every stored conversation. The stored conversation stays with the old key (see Stored Conversations). That includes an old key shown without its proof. The proof proves continuity, nothing proves its absence, and older peers have none. It breaks the chat until the real peer's next key, visibly. Across pools the device key differs and nothing carries over: a session's end forgets every key, so a person met in another pool is a first contact, with no warning and no silent acceptance. The device key signs and never encrypts, so its compromise exposes no past message; it lets the thief pass as that device to peers that already met it in that pool, without the warning.

Who a Chat Message Is From

A message's author is whoever's key opened it, as the transport named them: the link check in a local pool, the relay in a remote one, the envelope's signature for a relayed message.

  • One sender. An encrypted payload names its sender, which picks the key that opens it, and so does the message around it. They have to be the same, or the message is dropped. Otherwise anyone could encrypt with their own key and post under someone else's name.
  • What the payload says of itself. The sender and name inside the encrypted payload are overwritten: the message shows under the authenticated ID and the name the roster has for it, with whatever tells that name from someone else's (see Host Approval). A local pool's ID is the name; a remote pool's is a key, and someone not on the roster shows as the start of it. A payload marked as a system row is dropped, as that has no author on screen and would be the app speaking. A message arrives with no reactions and no poll votes: those come by themselves, under their own authenticated sender. Its time can be up to 10 minutes old, which covers a wait for a key or in a relay's queue, and is otherwise shown as that old, and is never later than now. A reply's quote is not shown as sent: the view shows this device's own copy of the message it points at, in the same chat, with that message's real sender and text, and says the original is not available if it has none. The avatar in the payload, the mentions and any time within those 10 minutes are still the sender's word.
  • History is the host's word. A history sync carries stored messages with any author and time, so it is taken only from the session's host, and only within 120 s of this device's request or of the last part of the answer. No claimed author is ever treated as this device: what this device said itself it already has, and is told apart by message ID. A device that has lost its copy sees its own past messages as someone else's under its own name. It keeps old times, reactions and poll votes, as that is how a joiner learns of them, and system rows and times later than now are dropped from it as from any message. It carries no photos or voice notes, as nobody gets those from before they joined and a host cannot tell someone new from someone returning reliably: such a message shows that its media is not available, a returning member keeps what it already has, and what was sent while it was away stays unavailable on that device. The host can still attribute history, reactions and votes to anyone, and nothing can tell: anything in a history sync is only as good as the host.

Stored Conversations

A local pool's peer ID is the name its owner picked, so a conversation stored under that name would otherwise open for whoever uses the name next. They would receive nothing, but this device's user would see the old chat under a stranger.

  • Binding. A private conversation stores a hash of the key it was with: the other person's device key if it vouched for their chat key, otherwise that chat key. It opens only for the same one. A device key belongs to one pool, so the same person gets a separate conversation in each pool, and nothing links them. A peer with no device key, such as StealthOS 1.5.0, gets a new one for each session, as each launch is a new chat key. A changed key with no proof starts another, and a proved rename moves only what is bound to that device key.
  • Past chats. A conversation nobody here has shown the key for is listed in a row of its own, for reading, with nobody to answer. Its row ID starts with a value drawn at launch, so that no name can pass for one, and nothing can be sent to it. This includes everything stored before 1.6.0, which has no binding: it is never deleted and never shown as the person now using the name. Each device key has its own row. The conversations of unproved chat keys under one name share a row, as such a key lasts one session and an older peer would otherwise add a row a session: they were all that name, and nothing says they were one person. Nothing unproved joins a live row.
  • A chat on screen. When someone's key changes with no proof, a chat with them that is open stays as it is, with the notice in it, and what is written next goes to the new conversation. The next time it is opened, it shows that alone, and the rest is in a past chat's row. When the pool ends, an open private chat turns into the past chat's row of the key it was with, so that nobody can write to it and locking the app does not blank it.
  • Before a key. A message written to someone who has shown no key yet is kept in memory, like the send queue it is waiting in, and stored once it is known whose conversation it is. At most 50 per person, media included; they go with the pool or the app.
  • Groups. A member of a local pool stores its group under the host's name and a hash of the pool key it pinned, or of the pool's ID for a host from before 1.6.0, which has no pool key. A new pool is a new group, even from the same host. A host's own group stays under its own name and spans its pools, for the host to read. So each stored message says which pool it was said in, and a history sync gives only this pool's: another pool's would show on a member as new messages, with their own lines under someone else's name. A nearby pool is told by its session's ID, and a remote pool by the relay's pool ID, as it gets a new session each time this device authenticates with the relay and is still the same pool to whoever comes back. What a host stored before 1.6.0 says no pool, and is given to nobody, which matters only to a remote pool that was running across the update. This is weaker than a private chat's binding: the name, the pool key and the pool's ID are all in the advert, so a fake advert copying all three, and chosen from the list by someone who was in that pool, shows them that group's stored messages under the fake host, which receives nothing and can sign nothing in that key (see First contact). Binding a group to a proof from its host would leave no group to show until the host's key arrives.
  • Remote pools are unchanged. The ID is the identity key, the relay checks a signature with it, and a stored conversation goes on opening under it. Nothing stored in a remote pool opens for the same ID outside it, as this device's own ID there is part of what is checked.

Clocks

A join proof carries the time, and a verifier refuses one more than 10 minutes from its own clock. Nothing better is available, as an invitation has no round trip for a challenge. So a wrong clock has consequences on both sides: a joiner whose clock is off is refused everywhere, and a host or relaying member more than 10 minutes off refuses every honest joiner. A member that far off also refuses the host's dated statements.

  • The joiner hears why. Declining an invitation carries no words, and connecting a stranger just to tell them would hand them a link. So someone with the right code whose proof holds, except for its time, is refused, and the refuser's advert is restarted with a tag only the holder of that proof can recognise (a hash of its nonce). The joiner believes it only from the peer it invited, since whoever else a proof passed through holds its nonce, and then says that its date and time do not match the pool's and that either clock may be off, in place of "Could not join". Nobody without the code hears anything of clocks, nor does a blocked device, and to anyone else a tag means nothing. A restart happens at most every 30 s and four times a pool, and not while someone is waiting on an answer through that advertiser: that joiner just hears "Could not join". The tag is not proof of anything. A first hop can post it for a joiner it was going to refuse anyway, which says the time when the reason was something else. Restarting makes the pool's row blink on nearby lists.
  • The refuser logs it, and may give its own user a hint: two different device keys off in the same direction by about the same amount (within 5 minutes) on invitations it checked itself, with no good proof since, or three statements from the host dated well ahead of this device, which no replay can produce. The hint says either clock may be off and to change nothing unless this device's looks wrong, because it proves nothing about this device. Anyone can mint two keys and date two proofs, and with a code that is all it takes: someone who then "corrects" a right clock locks everyone out. So it is given once a pool and not again within the hour. Requests forwarded by a member are not counted, as this device checked no code for them. One device's wrong clock says nothing. Old statements say nothing either: anyone can show those.

A Locked App

The app lock evicts the store keys. A pool is not ended by it: people can still reach this device, and Pool Chat goes on answering with no window open.

  • Messages. While locked, no decrypted message stays in memory, in the background session or in an open window: each is sealed to the store as it arrives, and each list is dropped at the lock, with the conversation cache, the previews and the message being replied to. They come back from the store at the unlock. Whenever no window is open, the background session keeps at most 50 messages of each chat in any case.
  • Notifications. From the background session or a locked app they carry no sender, no title and no content, just one line in the person's language. The system's notification permission applies; there is no setting for this inside the app.
  • Accepted limit: chat session keys stay in memory. Evicting them would lose every message arriving while locked, because the sender encrypts for a session key this device would no longer have, and the pool has no other way of delivering them later. So anyone able to read this device's memory while it is locked with a pool running can read what arrives during that time, and what they captured earlier for that session. Leaving the pool ends that. A stricter option is possible, disconnecting on lock, at the cost of being unreachable while locked.
  • Other things not cleared. A message being composed in an open window stays, as do outgoing messages still waiting for a peer's key, which would be lost otherwise: at most 50 in the send queue, and for each person a copy of up to 50 to store once it is known whose conversation they belong to (see Stored Conversations), media included. A decrypted message passes through memory for as long as it takes to seal it. Reactions and poll votes arriving while locked have no message in memory to attach to, so each is sealed like a message and applied to the stored message at the unlock.

Brute-Force Protection

A global rate limiter tracks total wrong code attempts across all peers: 10 failures in 60 seconds triggers a 30-second cooldown. This cannot be bypassed by rotating peer identities. Everything that proves no device key relies on it alone, and a cooldown refuses everyone while it lasts, including the host's real guests. Per-device tracking via DeviceBlockListService is a supplementary defense against a device that keeps proving who it is.

Remote Relay Security

Provided by StealthRelay.

Layer Mechanism
Host Authentication Ed25519 signature over pool_id || timestamp || nonce where nonce is a server-issued per-connection challenge; timestamp window tightened to 30 seconds
Outer encryption layer Every frame between members is AES-256-GCM encrypted with a host-generated pool transport secret the relay never sees (StealthOS 1.5.0+; see Remote Transport Outer Layer). Pools with a StealthOS 1.4.x member or host run without it (the inner Pool Chat E2E layer still applies) and the app says so
Session Tokens 32-byte server-issued token required for all privileged operations from both host and guest peers; included in Forward frames for all roles (constant-time comparison)
Invitation Tokens Ed25519-signed URLs with HMAC proof-of-possession, configurable expiry and max uses, server_address bound in signature. From relay 0.6.0 the relay enforces them: a link that has expired, been used up or been revoked, or that was made for an earlier pool under the same ID, reaches no host, and a host's yes on one admits nobody. A yes uses a link up, and a refusal does not. A host refuses an ended or expired link itself as well, for an older relay's sake
Proof-of-Work SHA-256 hashcash (18-bit difficulty) required before join requests are forwarded to the host. The client refuses challenges that are not 32 bytes or ask for more than 26 bits (the relay's top tier), solves under a 90 s budget, cancels on disconnect, and fails the connection with a clear error otherwise
TLS Pinning SPKI SHA-256 hash pinning via URLSessionDelegate (optional, for production deployments)
Server Claiming One-time claim code binds a server to a host identity; the code is destroyed after use
Display Name Sanitization All display names are stripped of control characters and newlines, then truncated to 64 characters before logging or storage
Per-Pool Isolation Pending joins, session tokens, and server URLs are all scoped per-pool, so there is no cross-pool state leakage

Remote Transport Outer Layer

Remote pools talk through StealthRelay. Pool Chat content is already end-to-end encrypted per peer pair, but the relay operator would otherwise see every frame's metadata (message types, sender names, key exchanges, timing per peer). The outer layer encrypts every member-to-member frame with a pool transport secret that only the host and approved members hold. Before 1.5.0 this layer existed in code but was never switched on, because no secret was ever distributed.

Distribution (decision). The relay builds invitations and knows the token secret in them, so neither the invitation's server-built part nor anything sent through the relay in the clear can carry the secret. Design:

  1. The host generates a random 256-bit transport secret (epoch 1) when the pool starts, stored in the Keychain.
  2. For each invitation the host also generates a random 256-bit invite key and appends #v2.<base64url(0x02 || inviteKey || hostIdentityKey)> to the invitation URL locally. URL fragments are never sent to a server, and the invitation travels out of band (QR, AirDrop, message), so the relay never learns the invite key. StealthOS 1.4.x ignores the fragment. A present but malformed fragment rejects the invitation, so it cannot be silently stripped to "no fragment".
  3. After joining, the member checks that the relay's host_peer_id is the host key from the fragment (the relay names hosts and members by their Ed25519 identity keys), then sends a key request: an ephemeral X25519 key and a nonce, signed with its member identity key and MAC'd with the invite key.
  4. The host verifies that it approved this member itself and has not removed them, the signature, the invite MAC (first join) or the enrolment (later reconnects, rotations), the nonce and the timestamp. It then seals the secret under HKDF(X25519(hostEphemeral, memberEphemeral), salt: SHA-256(request), info: "STEALTH_WS_TRANSPORT_KEY_V2" || epoch || inviteKey) and signs the grant with the host identity key.

The relay forwards these control messages ("SWK2" || JSON) but never sees the secret. It cannot impersonate a joiner without the invite key, cannot impersonate the host without the host identity key, and cannot learn the secret from the sealed grant. It says who a request is from, though, so an invite proof alone is not enough: someone holding a link gets the secret only if the host approved that identity. The approval is kept with the host's record until the secret is fetched, written before the relay is told of it. A request that arrives first is dropped, and the member's retry finds the approval. What the host approves is the name and the key the relay states on the request, and nothing ties those together at that point: the join proof covers the pool, a time and a nonce and not the key, and the relay issued the token it is made with, so it could make one. An invite proof does cover the key, and comes only after the approval. So a hostile relay and someone holding a live link can put their key on another person's request and keep the name, and that key gets the approval and the secret, while the person waits. Someone with a live link could ask under that name without the relay's help anyway. The request row therefore shows the start of the key, and the joiner's waiting screen shows the same, for anyone who wants to compare. Tying the key to the request needs an invite proof at that point, and is a change to the protocol. List rows show the start of the key too, and a row whose name reads like someone's who is or was in the pool, or mixes scripts, says so. Embedding the secret directly in the invitation was rejected: anyone who saw the link, approved or not, could read the pool's outer layer forever, and the secret could never be rotated.

Frames. "SWO2" || epoch u32 || sender session u64 || counter u64 || timestamp ms i64 || nonce(12) || AES-256-GCM(frame) || tag, key HKDF(secret, salt: poolID, info: "STEALTH_WS_OUTER_V2" || epoch), AAD = header || poolID || sender peer ID. Receivers reject frames older than 5 minutes or more than 60 s in the future, and keep a replay window per sender session. Once a pool is protected, plaintext frames are refused (fail closed). A member that is still securing holds its outgoing frames rather than send them in the clear.

Versions and legacy peers. The host turns protection on when every member present has enrolled. A pool that has a 1.4.x member at that point stays unprotected, and everyone is told (OuterLayerStatus.unprotected(legacyPeers:), fed into the "older version" warning). Until a pool has been protected, a host holds its app frames, such as the profile it sends when someone arrives, while it cannot tell which kind of pool this is: anyone present is still fetching the secret. They go sealed once that person has it, or in plaintext once someone is shown to be on 1.4.x, by sending a plaintext frame or by not fetching the secret within 10 s. So a member on a slow link that takes longer than that is treated as being on 1.4.x, and the frames held for it are then readable by the relay. A pool that already has a 1.4.x member is not delayed. A host alone hands the relay nothing of this kind. Signed control messages ("SWK2") are never held: they are how the secret gets there. Once a pool has been protected it is never turned off. A 1.4.x member who joins later is excluded rather than causing a downgrade, and the UI says so. Members persist "this pool is protected" and refuse a later "protection off" announcement (downgradeRefused). Invitations without a fragment come from a 1.4.x host: such sessions run unprotected and are flagged.

Rotation. When the host removes or kicks a member, its enrolment is deleted and a new secret (epoch + 1) is generated. Remaining members fetch it with a signed request, and the host switches after they have, or when the last one it waits for has left, or after 10 s. Frames under the previous epoch are accepted for 30 s, never from the removed member. A removed member cannot obtain the new secret: the host keeps who it removed in its record, across relaunches, at most the newest 256, and drops their requests. Only the host's own approval lifts that, never the relay's word that they are back.

Removal ends earlier links. The removed person may hold any invite key made so far, so a removal revokes them all, and the host tells the relay to revoke the invitations as well, which is what someone holding such a link hears: it fails at once rather than leaving them waiting. The host's list of invitations drops them and it hears why. A socket that is down at that moment tells the relay as soon as it is back; until then this host refuses those links itself, and it remembers one for a day past its expiry. Someone approved before the removal can still use the key they came with, and so can an enrolled member whose first answer was lost. A request still waiting on the host's screen at that moment is declined and its row goes, as is one that arrives afterwards naming such an invitation: it could be the person removed, asking under another name, and the host would have no way of telling. The host hears that earlier links ended and to make a new one, and the joiner hears that the link no longer works (a 1.5.x joiner hears that it was refused). A host that first ran 1.5.0 has invite keys with no relay token, which only the host refuses: someone with such a link can be approved and then never get the secret, and shows as still securing. Anyone approved, on a host that later updated, and not enrolled by then, is in that position as well.

Blocking a request is not a removal. Someone waiting on a relay was never in the pool and holds no secret. So blocking their row refuses them and puts their key on the block list, with no rotation, no link ended and nobody else's request declined: otherwise anyone with a link could do all that to a pool by asking and being blocked. A blocked key asking again is declined with no row. A blocked person who still holds the link can ask again under a new key, and is asked about like anyone else, as a key is all a relay request has to go by and a name refuses nobody. Someone who already holds the secret, asking again and blocked, is removed in full. To end a link, the host uses the invites list: its close button tells the relay and refuses the link from then on, and declines a request still waiting on it, unless its person already holds the secret. Other links go on working. Anyone approved before that keeps what they came with.

A request a host takes its time over. A relay forgets a join request after 60 seconds and tells nobody: an Accept after that goes nowhere. So a waiting joiner asks again every 45 seconds with a new proof, until it is answered or its link has expired. To the host that is the same request, with no new row or notification. A key already approved or blocked gets its answer when it asks again.

Timers. Key request retries, the rotation switch and counting a silent peer as an older version all run on a one-second timer, restarted every time this device authenticates with the relay. A StealthOS 1.5.0 member stops its timer at its first reconnect and has no way to restart it. It still asks for a key on every connect and on an announcement naming a secret it lacks, but one such request lost leaves it without the new secret until it next reconnects.

What a request costs. Each answer takes a signature check, an X25519 agreement, a signature and two frames, on the main actor. So someone neither enrolled nor approved costs decoding the frame and looking them up in the record, with no cryptography and nothing kept about them, and everyone else has 8 requests and then one every 2 s, which a member asking when it connects and every 5 s never reaches. Reconnecting does not refill that: it is forgotten once it has filled up by itself. An enrolled member's proof changes nothing and writes nothing. Request nonces are forgotten after 10 minutes by the clock, not only on the next success.

Relay Envelope Integrity (HMAC-SHA256)

Envelope integrity is versioned. Version 1 is what StealthOS 1.4.x and older emit; version 2 ships in StealthOS 1.5.0.

v1 (kept bit-exact for older peers) MACs originPeerID and destinationPeerID (length-prefixed, but nil and "" encode identically), poolID, messageID, the constant maxTTL and the timestamp (native byte order). It does not cover encryptedPayload.

v2 adds a second MAC under a separate key, HKDF-SHA256(secret, salt: poolID || "StealthOS-RelayEnvelope-HMAC-Salt", info: "envelope-integrity-v2"), over (all integers big-endian):

u32 len || "STEALTH_RELAY_ENVELOPE_V2"
u32 len || originPeerID (UTF-8)
0x00 (broadcast)  |  0x01 || u32 len || destinationPeerID (UTF-8)
poolID (16) || messageID (16)
u32 originTTL
u64 IEEE-754 bits of the JSON timestamp (seconds since 2001)
u64 len || encryptedPayload

New JSON keys: envelopeVersion (2), originTTL, envelopeHMACV2, originSignature. A 1.5.0 origin attaches both MACs, so 1.4.x relays and receivers keep working. 1.5.0 receivers verify only the v2 MAC whenever any v2 key is present, so stripping or corrupting part of v2 never falls back to v1.

The per-hop ttl and hopPath cannot be MAC'd by the origin. v2 binds originTTL instead and enforces 1 <= ttl <= originTTL <= 5, hopPath[0] == originPeerID and hopPath.count + ttl == originTTL + 1: a relay can never grant more hops than the origin did. Timestamps more than 120 s in the future are rejected so an envelope cannot outlive the deduplication cache.

Downgrade rule. PeerProtocolLedger (shared through ConnectionPoolManager.wireProtocolLedger) remembers every origin that has sent a valid v2 envelope. A v1-only envelope from such an origin is refused for the rest of the session. A v1-only envelope from an origin never seen with v2 is accepted under the default LegacyPeerPolicy.warn and the origin is published in MeshRelayService.legacyEnvelopeOrigins for an "older, less secure version" indicator. Set the ledger's policy to .refuse to drop v1 entirely.

Per-sender authenticity. The pool MAC only proves "a member of this pool": any member could forge any origin. v2 therefore also carries originSignature, an Ed25519 signature by the origin over exactly the v2 MAC input, made with a per-session signing key (EnvelopeSigningKeys).

  • The key travels in the E2E key exchange (KeyExchangePayload.signingPublicKey) with signingKeyBinding, a signature by that key over u32 len || "STEALTH_ENVELOPE_SIGNING_KEY_V2" || u32 len || peerID || poolID (16) || X25519 key (32) || Ed25519 key (32). The signing key is thereby tied to the peer ID, the pool and the peer's E2E key, so verifying the E2E fingerprint out of band also authenticates it.
  • Receivers keep the first valid key per peer for the session (TOFU). A different key for the same peer is refused and reported (EnvelopeSigningKeys.events) until the peer leaves (its direct link drops, or the host's word arrives that it left) or the pool session ends; a new session rotates our own key. A new key replaces the old one with no event only if the peer's own device key vouched for it: the continuity proof covers this signing key along with the chat key, the chat key is a new one, and the counter is higher. That is the same device in a new session, and a rejoin can arrive before word of the departure. Without all of that, someone who saw a proof could attach their own signing key to it and pass as that peer. A rejoin that has none of it, from a peer too old to send it, waits for word of the departure. A relayed introduction carries no such vouching, and a rejoiner arriving that way before word of its departure is refused until that word comes.
  • Verification order: pool MAC (cheap filter), routing invariants, then the signature. A missing or invalid signature is rejected. If this node has no key for the origin (it never exchanged keys with it) the envelope may be forwarded but is never delivered.
  • The signature covers the pool ID, so a member of another pool cannot replay it even after re-MACing.

The pool MAC key comes from PoolIntegritySecret: SHA-256(pool code), the one secret every member of a local pool shares (the host generates the code, joiners type it). This is the derivation 1.4.x already used. Pools without a code, and remote pools, have no pool-wide secret, so mesh relaying is disabled for them rather than run on a made-up key. Feature packages must use ConnectionPoolManager.meshRelay instead of creating their own MeshRelayService.

Introductions. A v2 envelope may carry originIntroduction: the origin's X25519 E2E key, Ed25519 signing key and their binding (128 bytes, covered by the MAC and the signature). A destination with no key for the origin verifies the binding (origin ID, pool, both keys) and the signature end to end, then registers the key (TOFU). A different key for an origin it already knows is refused (rejectedKeyChange) and offered to the user (pendingKeyChangeFingerprint / acceptPendingKeyChange). Relays forward introductions addressed to others without learning or judging them.

Stripped by a 1.4.x relay. A 1.4.x relay drops the v2 fields when it re-encodes an envelope. If the origin has already proven v2, the stripped copy is refused (fail closed). When it was addressed to us, MeshRelayService.unverifiableRelayedMessage reports it (at most once a minute per origin) so the app can tell the user a message from that peer could not be verified. The content is never delivered.

Golden vectors: Tests/ConnectionPoolTests/Golden/relay_envelope_hmac_v2.json (generated independently by generate_relay_envelope_hmac_v2.py).

Relayed Key Exchange

Peers connected only through other peers exchange E2E keys over signed v2 envelopes with introductions (Pool Chat's RelayedKeyExchangeCoordinator):

  • The request and response introduce each side's X25519 and Ed25519 keys. Intermediate hops cannot alter or swap them in transit, because the binding, the MAC and the signature cover them.
  • After first contact, a hop cannot substitute keys. A different signing key is refused and reported, and a different X25519 key raises the chat TOFU key-change event, exactly as for a direct peer.
  • Replays are refused: envelopes are deduplicated and expire, each nonce is accepted once, and a response must answer our outstanding request.
  • Limit (by design of any first contact without a prior shared secret): if a malicious device is the only path at the very first contact, it can introduce its own keys to both sides. This cannot be prevented automatically, but it is detectable. The safety code on the security-code screen then differs between the two devices, so comparing it in person or on a call exposes the attacker.

Loop and Amplification Prevention

Mechanism What it prevents
TTL (default 5, max 5) Messages circulating indefinitely
Hop path Relaying to a peer already in the path
Deduplication cache (10,000 entries, 5-min expiry) Processing the same message twice
Message expiry (5 minutes) Replay of old messages
Pool ID validation Cross-pool message injection
Topology broadcast freshness (120s max age) Replay of stale routing info
Topology broadcast HMAC (HMAC-SHA256) Unsigned or tampered topology broadcasts are rejected when a pool shared secret is set
WebSocket frame size limit (1 MB) Memory exhaustion from oversized frames sent by malicious servers

Inbound Size Limits

All received data, on both the primary MCSessionDelegate and the relay session delegate, is checked against a 10 MB hard limit before any decoding is attempted.

Bounds on What a Member Can Ask For

Mechanism Bound
Bridging budget Per link: 500 messages a second (the relay's own figure for real-time media) with a burst of 1000, and 16 MB a second with a burst of 64 MB. A message over budget still reaches this device; it goes no further
Control ceiling .system and .peerInfo under 64 KB do not use the budget, so a flood cannot stop them. They have their own ceiling per link: 50 a second with a burst of 200. On a member of a pool with a key, the host's verified roster entries and removals are exempt from that too: their signature and time were checked, and a repeat is refused before this. A removal is kept whatever the budget says
Targeted messages routeTo names who they are for, and each hop passes them only towards those: their own link, the link they are behind, or upstream. Only a target a device cannot place (someone behind an older member) goes on every link. A message naming more people than a pool can hold (8) is refused outright. Older peers ignore the field and flood as before
Forwarded requests 16 waiting on a host, at most 4 from one link, and only from a link the device let in. The same on a member passing requests up, which also forgets a request after 70 s
Approvals Count against the pool's size until used or lapsed (90 s), are checked against it again when answered, and are good for one arrival, so a repeat causes no second announcement. At most 4 outstanding from one link, with auto-accept on or off
People behind a link A member can invent people: a name asked for and reported as arrived at once uses up the approval and is listed until the link goes or the host removes them. So one link's approvals and arrivals together can take at most half the pool (at least one), and the rest stays for everyone else. A pool can therefore be held half full by forged names, visibly: they are rows the host can remove. An honest pool whose joiners can reach only one member is limited the same way
Chat from someone not counted in On a member: at most 32 messages and 1 MB from one name, 16 names with at most 8 from one link and the oldest going first, about 30 s, then dropped. Nothing is passed to Pool Chat until the host counts them in. A host drops it
Names Inbound names at most 128 characters on one line, avatars at most 64 bytes, and at most 256 names remembered this pool
Removals told to a newcomer The 32 newest still in force, each one signed control message on the newcomer's link. Someone behind a member gets them through that member, which passes them to everyone below it
Records of people What a host keeps about a person ends when they leave. An approval that was never used leaves nothing behind
Discovery info Names are shortened to keep it under MultipeerConnectivity's 400 bytes; the pool key and scope never are

Separate Relay Service Type

Relay discovery operates on a distinct Bonjour service type to prevent DTLS handshake state from colliding with the primary session. The relay session uses its own MCSession, MCPeerID, and delegate handler, fully isolated from the primary connection.

Pool Persistence Across Host Disconnect

The relay decouples pool authority from host presence: the pool's identity is the bound Ed25519 public key, not the host's current WebSocket connection. When the host's connection drops, the relay marks the pool host-offline (broadcasting pool_host_status { online: false, offline_since }) but keeps the pool, peers, invitations, and per-pool config in place. The host's session token is wiped so a leaked token cannot be replayed.

The host reclaims the pool by re-running host_auth with the same Ed25519 key and the same pool_id. The relay rebinds in place and emits pool_host_status { online: true }. A host_auth with a different pubkey targeting the same pool_id is rejected with 403 pool host pubkey mismatch. New invitations and join approvals still require the host to be live (no auto-approve): a JoinRequest while host-offline returns JoinRejected.reason = "host_offline_unavailable".

A 60-second eviction sweep destroys pools whose host has been offline longer than [pool] host_offline_ttl_secs (default 24h) or that are simultaneously empty and host-offline for empty_grace_secs (default 5min). Targeted forwards to an offline host buffer in the existing per-peer queue and replay on rebind.

A relay keeps its pools in memory, so one that restarts has none: every invitation, approval and pool is gone, and no link made before it works, whatever became of it. A host authenticating again gets a new pool under the same ID and re-registers nothing but that, and everyone who was in it gets 404 pool_not_found or 403 not_approved and needs a new invitation, under a new key. The relay's pool_host_status { online: true } comes straight after host_auth only for a pool it kept. When it has not come within about 4 seconds, the host counts everyone it had listed as gone. A saved server the host never left opens that same pool_id again from its row, even after the relay could not be reached at launch, so a relay that kept the pool keeps everyone in it. After the host leaves or closes, the row opens a new pool.

A host or member who has been in a pool and loses the relay keeps trying, for as long as they have not left: gaps of 1, 2, 4 s and so on up to 5 minutes, each shortened by up to a fifth at random so that a pool does not arrive all at once, and the pool's screens and Pool Chat say that it is reconnecting and that nobody is reachable. After about half a minute nobody is listed as here. Coming back to the app, or Try Now, tries at once and starts the gaps again. A refusal ends it: removed, not approved, pool gone, or any other answer but busy (408, 429, 5xx). A kill switch or blocked route ends the attempts too, says so, and starts them again when the route opens, as the route is decided on every attempt (PoolEgress). The app lock does not touch it: reconnecting needs no store key, as the identity is held in memory, and a device locked in the background can therefore authenticate. A suspended app makes no attempts. A connection that has never got anywhere gives up after 5 attempts, about half a minute, as it may be a wrong address, and that includes a saved pool opened at launch with its relay down: its row offers it again.

A relay names its people to a host only as they arrive, and a host that restarted has lost its chat keys and its list. So a member who hears online: true after online: false runs the same member_rejoin it would after a dropped connection, with the identity the relay already approved: no new approval and no new authority. A host whose invitation request goes unanswered, because a relay dropped its pool while it stood empty, re-runs host_auth for the same pool_id once and asks again.

Member Rejoin Without Host

The host's approval is a one-time act. The relay tracks an approved_peers set (Ed25519 pubkeys) per pool, populated when the host sends JoinApproval { approved: true } and pruned on KickPeer. Once approved, a member can reconnect indefinitely via the member_rejoin frame:

  • Client signs b"STEALTH_MEMBER_REJOIN_V1:" || pool_uuid_bytes || timestamp_be || nonce_raw with their persistent Ed25519 key
  • Relay verifies the signature, checks the pubkey against approved_peers, and issues JoinAccepted directly, with no host round-trip
  • Works whether the host is online or offline
  • Same-pubkey reconnect from a fresh connection evicts the stale connection with Kicked { reason: "rejoined_elsewhere" }
  • A 403 not_approved or 404 pool_not_found is a terminal signal: the iOS client purges the saved identity and RemoteMemberRecord, surfacing "membership revoked" or "pool no longer exists", and is out of the pool everywhere, chat included
  • Someone opening an invitation link who has a key saved for that pool tries member_rejoin first. If the relay does not know the key, they ask with the link instead, under that same key, as whatever the host decided about it still holds: a blocked key is declined again, and a removed one needs the host's approval
  • Removed while offline. A kick ends the approval whether or not the relay has them connected, from relay 0.6.0. On an older relay someone offline when removed keeps theirs and can member_rejoin. Either way the host keeps its own record of the removal, gives them no secret and ignores the relay's word that they are back, so they can read nothing in a protected pool. In an unprotected pool on an older relay they are back on the relay, where frames are plain.

This makes host disconnection truly transparent. Members can close the app, restart their device, or lose Wi-Fi for an hour, and still pick up right where they left off.

Tunnel Exit (VPN-like)

The relay can act as a network exit for authenticated pool members, opening real TCP/UDP sockets to internet destinations and bridging bytes back over the WebSocket. Three gates apply on every tunnel_open:

  1. Server-wide: the relay operator must set [tunnel] enabled = true.
  2. Per-pool: the pool host approves members via update_pool_config { tunnel_exit_enabled: true }. The host themselves bypasses this gate; it controls member access.
  3. Authentication: the connection must have completed host_auth_success or join_accepted.

Failures at any gate respond with tunnel_close { reason: "policy_denied" }. The relay also default-denies SSRF-prone targets (RFC1918, loopback, link-local, ULA) and abuse-prone ports (SMTP, IRC), which operators can override.

Bulk bytes ride binary WebSocket frames on the same port, so there is no second listener and no proxy reconfiguration:

TUNNEL_DATA   [0x01][stream_id u32 BE][sequence u32 BE][payload ≤ 32 KiB]
TUNNEL_UDP    [0x02][stream_id u32 BE][datagram]

Type byte 0x00 is reserved as a framing-error sentinel and 0x80..=0xFF is reserved for future channels; both are rejected. Binary frames received before authentication terminate the WebSocket with code 1008 policy violation. The control plane (tunnel_open, tunnel_close, tunnel_window_update, tunnel_dns_query, tunnel_dns_response, tunnel_error) stays JSON for debuggability.

What the relay can and cannot see when acting as an exit:

Data Visible to Relay? Notes
Destination hostname / port Yes Required for the upstream connect
TCP / UDP byte counts and timing Yes Inherent in the bridge
HTTPS payload (TLS body) No TLS is end-to-end between member and destination
Plain HTTP body Yes Plain HTTP is unencrypted by design

For client-side usage, see Tunnel Exit Through the Relay.

Reporting a Vulnerability

See SECURITY.md. Report privately to security@olib.ai rather than opening a public issue.