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.)
- 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 ownreceiveUrl/routingToken) and save its connection record with statuscodeSentbefore 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. - 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 in00-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 statuscxnSent. - Joiner sends a
connection/cxnEstablishedmessage, 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 (see04-access-control.mdfor 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). - 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 becomescxnEstablished. - Inviter sends a
connection/completemessage 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- and DH public key.
- 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 asubscription/refreshSubscriptionsmessage to re-synchronize any subscriptions already established before the connection completed (see04-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.