KeyPears is a federated protocol. Any domain can host a KeyPears server, and
users across different domains can communicate seamlessly. The model is
analogous to email: your address is name@domain, and the domain determines
where your data lives.
Federation is intentionally simple. Each domain's server is the authority for the current public keys of the addresses it hosts, just as an email domain is the authority for routing mail to its users. KeyPears does not add a global key transparency system or a no-trust hosted-server layer. If you do not trust a hosted server to publish honest keys, run the server for your own domain.
Discovery: keypears.json
When a server needs to interact with a user on another domain, it fetches the well-known configuration:
GET https://{domain}/.well-known/keypears.json
Response:
{
"apiDomain": "keypears.acme.com"
}
| Field | Type | Description |
|---|---|---|
apiDomain | string | Domain hosting the KeyPears API (at /api) |
admin | string | (Optional) Admin's full KeyPears address |
The API URL is derived as https://{apiDomain}/api. All server-to-server
communication goes through this endpoint.
Caching
Servers should cache keypears.json responses. The file changes rarely
(only when migrating hosting). The reference implementation uses an
in-memory cache with a 1-minute TTL — short enough that admin field
changes propagate quickly (the old admin loses access within a minute), but
long enough to absorb bursts of cross-domain traffic.
Three deployment patterns
1. Self-hosted
The address domain and API domain are the same. Users sign up directly at the domain.
// acme.com/.well-known/keypears.json
{ "apiDomain": "acme.com" }
Users get addresses like alice@acme.com and the API is at
https://acme.com/api.
2. Subdomain
A business runs the KeyPears API on a subdomain, keeping the main domain free for other uses.
// acme.com/.well-known/keypears.json
{ "apiDomain": "keypears.acme.com" }
Users have @acme.com addresses but the API runs at
https://keypears.acme.com/api.
3. Third-party hosted
A domain owner delegates their KeyPears service to another operator entirely.
// acme.com/.well-known/keypears.json
{
"apiDomain": "keypears.com",
"admin": "acme-admin@keypears.com"
}
The admin field names an existing KeyPears user who can manage users for this
domain. The admin is verified against keypears.json on every privileged action.
If the admin field changes, the old admin immediately loses access.
The same admin field and claim flow also apply when you want to administer
your own server's primary domain. For that case — the symmetric one — see
Claiming your primary domain
in the self-hosting guide, which walks through the KEYPEARS_ADMIN env var
and the setup sequence.
Key discovery
To find a user's current public key, the sender's server calls the recipient's server via oRPC:
const client = createRemoteClient(recipientApiUrl);
const result = await client.getPublicKey({ address: "alice@acme.com" });
// result.ed25519PublicKey = "..." (Ed25519 verification key, 32 bytes)
// result.x25519PublicKey = "..." (X25519 DH public key, 32 bytes)
// result.signingPublicKey = "..." (ML-DSA-65 verification key, 1,952 bytes)
// result.encapPublicKey = "..." (ML-KEM-768 encapsulation key, 1,184 bytes)
// result.keyNumber = 3 (sequence number of this key set)
The server returns the user's active public keys (the most recently rotated
key set): four public keys covering both classical and post-quantum algorithms,
plus a keyNumber identifying which key set this is. Ed25519 and ML-DSA-65 are
used together for composite signature verification. X25519 and ML-KEM-768 are
used together for hybrid key encapsulation.
This is an authoritative server response, not a transparency-backed identity proof. A server that is active and malicious for a domain can lie about future public keys for users on that domain. KeyPears accepts that trust boundary to keep the protocol simple enough for broad implementation; the mitigation is self-hosting or choosing a server you trust.
The sender includes recipientKeyNumber in the message so the recipient can
validate it against any retained key set, not just the currently-active one.
This avoids a race when the recipient rotates keys between key lookup and
message delivery.
Message delivery
Same domain
When sender and recipient are on the same server (including different hosted domains on the same server), messages are stored directly. Each user has their own copy of the message in their own channel view. No pull token or cross-domain verification is needed.
Cross domain (pull model)
All cross-domain communication is server-to-server. The client only talks to its own server. Cross-domain messages use a pull model rather than server-to-server push.
When alice@a.com sends a message to bob@b.com:
-
Client sends to own server — Alice's client calls
sendMessageon her server with the encrypted message and recipient address. -
Sender's server stores locally — Alice's server stores her copy of the message in her channel view.
-
Sender's server creates pending delivery — The message is stored in a
pending_deliveriestable with a random pull token (24-hour expiry). Only the SHA-256 hash of the token is stored. -
Sender's server notifies recipient — Alice's server calls
notifyMessageon Bob's server with the pull token and a proof-of-work solution (mined by Alice's client). -
Recipient verifies sender domain — Bob's server independently resolves
a.com/.well-known/keypears.jsonto discover Alice's API URL. TLS guarantees the response came from the real domain. -
Recipient pulls message — Bob's server calls
pullMessageon Alice's server (at the verified API URL) with the token. The pull is idempotent — if Bob's server fails mid-delivery, it can retry with the same token. Pending deliveries expire and are cleaned up automatically. -
Recipient stores — Bob's server verifies the message matches the notification, then stores it in Bob's channel view.
Why pull, not push?
The pull model provides domain verification without signing keys:
- The recipient independently discovers the sender's API URL via DNS + TLS.
- The sender can't provide a fake API URL — the recipient resolves it themselves.
- No server signing keys, no key exchange, no certificate management.
- Authentication comes from HTTPS/TLS — the same trust model the web uses.
The pull model verifies which domain is speaking. It does not remove trust from that domain's server as the authority for hosted users' current keys.
Because the pull happens synchronously during the send, the sender receives immediate confirmation of delivery or an immediate error. There is no outbox queue, no silent retry, and no delayed bounce notification.
Message structure
Each message stored on the server contains:
| Field | Description |
|---|---|
senderAddress | Full address (e.g. alice@acme.com) |
encryptedContent | Hybrid-encrypted message (recipient's copy) |
senderEncryptedContent | Hybrid-encrypted message (sender's copy) |
senderEd25519PubKey | Sender's Ed25519 public key |
senderX25519PubKey | Sender's X25519 public key |
senderMldsaPubKey | Sender's ML-DSA-65 verifying key |
recipientX25519PubKey | Recipient's X25519 public key |
recipientMlkemPubKey | Recipient's ML-KEM-768 encapsulation key |
senderSignature | Composite Ed25519 + ML-DSA-65 signature (3,374 bytes) |
isRead | Whether the recipient has viewed this message |
All keys are stored so the recipient can verify the composite signature and perform hybrid decryption, even after key rotation.
Message size limit
The encryptedContent field is limited to 50,000 hex characters (~25KB of
plaintext). This is enforced by both the sender's server (via Zod validation)
and the recipient's server (after pulling the message).
API procedures
All server-to-server communication uses oRPC — a type-safe RPC framework. The
API is mounted at /api and provides the following public procedures:
| Procedure | Description |
|---|---|
serverInfo | Returns domain info |
getPublicKey | Returns four active public keys (Ed25519, X25519, ML-DSA-65, ML-KEM-768) and keyNumber for an address |
getPowChallenge | Issues an authenticated PoW challenge (requires sender signature) |
notifyMessage | Notifies server of a new incoming message |
pullMessage | Serves a pending message delivery (idempotent, token-based) |
Migration
Because identity is bound to the domain (not the hosting provider), migrating between hosting arrangements is straightforward:
- Export data from the old server (users, keys, messages).
- Import data into the new server.
- Update
keypears.jsonto point to the new API domain. - Users keep their addresses —
alice@acme.comstill works.
This works for any migration path: hosted → self-hosted, self-hosted → hosted, or self-hosted → different self-hosted.
Migration is also the trust exit. If a hosted server's authority over current keys is unacceptable, move the address domain to infrastructure you control.