Docs Menu

Specs

spec/02-connections.md · shown exactly as it appears in the repository

02 — Connections

Status: Draft, v0.1. License: This text is CC-BY-4.0; the field names, enum values, and other machine-readable schema elements defined here are also MPL-2.0 — see LICENSE-DOCS and LICENSE.

Overview

A connection is established between two parties through a short sequence of encrypted messages relayed by a router, combined with an out-of-band exchange of a single-use connection code (e.g. an alphanumeric code or a a QR Code). The router's purpose is to route envelopes, period: it MUST NOT participate in, interpret, or hold any concept of connection semantics. From its point of view it only ever registers keys and forwards envelopes — and that isn't merely a policy restriction the router operator agrees to. Every envelope it forwards, including the handshake messages below, is encrypted with a key the router never holds, so even a misbehaving router has nothing to peek at.

Key exchange

Each party MUST generate a fresh X25519 key pair for the connection — consistent with the key-material isolation requirement in 00-overview.md, no connection may reuse a key pair from another connection. Once both parties have exchanged their public keys (see the handshake below), each independently computes the same shared secret via Diffie-Hellman.

The shared secret itself MUST NOT be transmitted between parties, or otherwise made visible to the router. Diffie-Hellman was chosen specifically because it satisfies this requirement by construction: each side derives the same value independently from its own private key and the other party's public key, so there is nothing to transmit even in principle — full cryptographic detail is specified in 07-security.md.

The shared secret is used to derive the symmetric key that encrypts all subsequent content exchanged over the connection. A party's private key MUST be discarded immediately after the shared secret is computed; only the shared secret is retained.

No addresses, only keys

There are no addresses in Bonafide. The router's entire data model — a sendKey, a domain, the row they key together, a receiveUrl, a routingToken — MUST NOT carry anything that identifies a person; all of it is routing information, nothing more. A sendKey on its own carries no information about who holds it or what it's for; it is only meaningful as a lookup into a row the router stores, keyed on (sendKey, domain) — a connection spanning multiple domains (see 03-cross-domain.md) may route each domain to a different receiveUrl, but domain here is routing information, not identity, same as everything else in the row. The receiveUrl in a registration points at a backend server, not a person — but it is stored per key, not as a separate concept of a registered "server"; the registry has no notion of grouping keys by the server they happen to share. In practice, a deployment registers every key it creates with the same receiveUrl, since all of its users are served by one backend endpoint — but that uniformity is a property of how an agent chooses to register keys, not something the router tracks, enforces, or needs to. The only thing in a registration specific to one user at all is the routingToken, and it is opaque ciphertext the router cannot read. The router, in other words, never sees, stores, or needs anything that identifies a person — only keys, a domain, a per-key server endpoint, and an encrypted blob it is told to echo back on delivery.

The router interface

The router exposes five operations. It stores rows keyed on (sendKey, domain), each holding a receiveUrl and an opaque routingToken; it MUST NOT inspect, validate, or act on anything else in a message.

A router deployment MUST restrict which callers may reach it to a configured IP allowlist — covering both the backends that register and send through it, and, once router federation exists (see below), any other router it forwards to or accepts forwarded traffic from. This is a network-level access boundary, not a per-message access-control decision — Layer 1/Layer 2 access control (00-overview.md) governs what content flows once a caller is already allowed to reach the router at all.

Operation Request Behavior
Register POST /send-key/register {sendKey, receiveUrl, routingToken, expiryDate, domain} Creates a row. 409 if (sendKey, domain) already exists.
Replace POST /send-key/replace {oldSendKey, newSendKey, domain} Atomically repoints an existing row at a new sendKey, keeping its receiveUrl/routingToken. 404 if not found, 409 if newSendKey is already taken.
Deregister POST /send-key/deregister {sendKey, domain} Removes a row.
Check GET /send-key/:cxnCode Returns {isCxnCode: boolean} — whether a row currently exists for this value, and has not expired.
Send POST /a2a-messaging/send {version, messageId, sendKey, domain, encryptedPayload} Looks up the row for (sendKey, domain) and forwards {version, messageId, sendKey, domain, encryptedPayload, routingToken} to its receiveUrl. 404 if no row exists.

Planned extension: router federation (non-normative preview)

Expected to ship by the end of 2026: a second table, keyed the same way as the one above — (sendKey, domain) — but pointing at another router's address instead of a receiveUrl. When Send looks up (sendKey, domain) and finds no row in the router's own table, it will consult this forward table; a match there means forwarding the message to that router rather than returning 404. This lets independently operated routers interoperate without every router needing to know about every key directly. The trust model between routers (how a forwarding router is itself IP-allowlisted, loop prevention, and related questions) is still being designed and is not specified here.

This solves delivery for a connection established across hosts; it does not by itself make that handshake decryptable without the ecosystem-wide bootstrap key (07-security.md). The two extensions are planned together for exactly that reason — see 07-security.md's own preview for the decryption half of this scenario.

A connection code is not a distinct primitive — it is simply a sendKey registered for one-time use, with an expiry date. The router treats it exactly like any other sendKey row.

The routingToken is opaque to the router: it is minted by the registering party (encrypted with a key only that party's deployment holds) and simply echoed back on every message the router forwards for that row. This is how a receiving agent recovers which local tenant database a message belongs to, without the router ever knowing.

Establishing a connection

Call the party who generates and shares the connection code the inviter, and the party who scans or opens it the joiner. (Either party may fill either role — nothing about these roles is persistent.)

  1. Inviter MUST generate an X25519 key pair (per Key exchange, above) and a connection code with enough randomness to be unguessable. A guessable code would let an attacker complete the handshake in the legitimate joiner's place; a connection code is single-use, so the blast radius of that attack is bounded to the one connection it was used for and ends the moment either party notices and deletes it — but that doesn't make randomness optional, since real content could be exchanged with the impersonator before it's noticed. The inviter MUST register the code with the router (Register, with the inviter's own receiveUrl/routingToken) and save its connection record with status codeSent before sharing the code with the joiner out-of-band — a joiner that receives the code before it's registered could fail an otherwise-valid attempt to use it.
  2. Joiner MUST confirm the code is live (Check) before generating anything — an expired or non-existent code would never deliver the resulting connection envelope to the inviter, so checking first avoids wasting the attempt. The joiner then generates their own X25519 key pair, independently generated for this connection like the inviter's in step 1 (per the key-material-isolation requirement in 00-overview.md), and registers a freshly generated key of their own with the router (Register) — the key by which the inviter will be able to reach them from now on. Their connection record is saved with status cxnSent.
  3. Joiner sends a connection / cxnEstablished message, addressed to the inviter's connection code, containing: a second freshly generated key (intended to become the key the inviter registers for itself — the joiner generates this on the inviter's behalf because the two parties are already exchanging keys in this message, and a separate round trip for the inviter to announce its own replacement key would add network chatter without adding anything), the key the joiner just registered (from step 2, so the inviter knows how to reach the joiner), the joiner's display name (entirely under the joiner's control — it MAY be blank) and DH public key, and the joiner's alias (see 04-access-control.md for what an alias is — in the current implementation, a party shares the same alias with every connection it has; a future implementation MAY generate a different alias per connection, or even more than one alias for the same connection). This is one-way: only the joiner's alias travels in this message. The inviter's alias follows later, after the connection completes (see step 6 below).
  4. Inviter receives this message and MUST atomically replace its temporary code registration with the new key the joiner generated (Replace) — atomicity ensures exactly one registration is ever live for this connection on the inviter's side: never both the old code and the new key at once (which would leave the connection code usable by a second party even after a joiner has already claimed it), and never neither (which would make the inviter briefly unreachable). The inviter adopts the key the joiner just registered as the key it will use to reach the joiner, computes the shared secret from its own private key and the joiner's public key, and discards its private key. Status becomes cxnEstablished.
  5. Inviter sends a connection / complete message back to the key the joiner registered, carrying its own display name (entirely under the inviter's control — it MAY be blank, same as the joiner's in step
    1. and DH public key.
  6. Joiner receives this, computes the same shared secret from its own private key and the inviter's public key, discards its private key, and sets its own status to cxnEstablished. It then sends a subscription / refreshSubscriptions message to re-synchronize any subscriptions already established before the connection completed (see 04-access-control.md). The inviter, on receiving this, replies with its own alias — the other half of the alias exchange that step 3 only did one-way. By this point the shared secret already exists on both sides, so the inviter's alias is sent properly encrypted with it, rather than with the bootstrap key step 3's message used.

Only the inviter's registered key changes over the course of the handshake (via the atomic Replace in step 4) — the key the joiner registers in step 2 is stable from the moment it's sent, which is why the inviter can already reach the joiner as soon as the cxnEstablished message arrives.

Connection states

Status Meaning
codeSent Inviter has registered a code and is waiting for a joiner.
unregistered Reserved for a connection record created but not yet registered with the router (not reached in the handshake above; present for implementation completeness).
cxnSent Joiner has registered its own key and sent its cxnEstablished message; awaiting the inviter's complete reply.
cxnEstablished Both parties have exchanged public keys, computed the shared secret, and may exchange content.

A connection MUST be in cxnEstablished status before any content exchange occurs — this is the connection-existence precondition from 00-overview.md's two-layer model made concrete: no other status permits it.

Bulk invitations

A single connection code may also be registered for repeated use (a bulk invitation), allowing many independent joiners to each establish their own separate, pairwise connection from the same shared code. Each resulting connection MUST be fully independent of every other connection established from the same bulk code — its own key material, its own non-linkability guarantee (per 00-overview.md), nothing that lets the inviter, another joiner, or an outside observer tie two of these connections back to the shared code or to each other. Each is established exactly as described above and is otherwise indistinguishable from a connection established one-to-one; the inviter's side simply tracks how many uses remain and stops accepting new joiners once none are left.

Ending a connection

Either party MAY end a connection, unilaterally — ending it needs no agreement from the other party. Doing so MUST be more than deregistering the key with the router: the party ending it MUST notify its peer (connection / delete), and both parties — the one ending the connection and the one receiving the delete notification — MUST delete all content they hold that was shared over that connection, along with the connection itself. Full propagation/deletion mechanics are specified in 06-propagation.md.