How Kleo's network works
Kleo is a client. It holds no keys, runs no cryptography of its own, and stores nothing anybody else needs. Everything it does is arranged so that the parts which are trusted — your node and your machine — are the only parts that have to be.
This document describes the whole network: who talks to whom, what is signed, what each message looks like on the wire, and what happens when a piece of it is dishonest or absent. THREAT_MODEL.md says what that adds up to in terms of guarantees; this one says how it is built.
1. The participants
flowchart TB
subgraph yours["Your machine"]
kleo["Kleo<br/>desktop app"]
node["plain-bitnames node<br/>wallet, keys, crypto<br/>JSON-RPC :6002"]
end
chain[("BitNames sidechain<br/>names, keys, commitments,<br/>payments")]
peer["Someone else's Kleo<br/>+ their node"]
subgraph infra["Infrastructure — replaceable, never trusted"]
hub["kleo-hub<br/>profiles · mail · addresses · channels<br/>HTTP :7777"]
relay["iroh relay<br/>hole punching, fallback path"]
end
kleo -->|"every key, every check"| node
node <-->|"P2P, port 4002"| chain
kleo -->|"signed uploads, signed requests"| hub
kleo <-->|"QUIC, ALPN kleo/dm/1"| peer
kleo -.->|"when no direct path exists"| relay
relay -.-> peer
hub -->|"checks signatures against its own node"| chain
peer --> chainKleo decides what to ask and how to report the answer. Your node holds the wallet and performs Ed25519 signing, ECIES encryption and every verification. The chain is the only source of truth about which key belongs to which name. Hubs and relays move and store bytes that everyone re-checks.
| Port | Who listens | What for |
|---|---|---|
6002 | plain-bitnames | JSON-RPC. Wallet methods live on the private address; keep it on loopback |
4002 | plain-bitnames | the sidechain's own peer-to-peer network |
7777 | kleo-hub | HTTP + JSON-RPC: profiles, mail, endpoint records, channel posts |
| ephemeral | Kleo | iroh QUIC, ALPN kleo/dm/1, for direct messages |
2. Addressing: names, keys, endpoints
| Thing | Shape | Where it lives | Changes |
|---|---|---|---|
| BitName | alice | the chain stores blake3(alice), never the text | never (the name is the identity) |
| Signing key | bn-svk1… bech32m | on-chain, in the name's data | when the owner rotates it |
| Encryption key | bn-enc1… bech32m | on-chain, in the name's data | when the owner rotates it |
| Profile commitment | 32-byte BLAKE3 | on-chain | on every profile change (one block) |
| Profile document | canonical JSON (RFC 8785) | a hub, or the name's own server | freely; the commitment is what pins it |
| Chat endpoint | iroh endpoint id — a public key | in the profile, under chat.endpoint / chat.endpoints | rarely (per device) |
| Prekey | X25519 public key | in the profile, under chat.prekey | when you rotate it; sessions already running are unaffected |
| Reachable address | ip:port, plus a relay URL | a hub's endpoint directory, signed by the endpoint key | constantly |
| Payment address | base58, 20 bytes, no checksum | on-chain; the address holding the name receives paymail | per payment |
The split in the last two rows is the point: what is permanent goes on-chain, and what changes every time a laptop moves networks is signed by the machine itself and kept wherever is convenient.
3. The rule everything follows
Nothing is believed because of where it came from. Every artifact carries a signature, and the key that signature is checked against comes from the chain, as reported by your own node.
| Artifact | Signed by | Checked against | A dishonest carrier can |
|---|---|---|---|
| Message / file | sender's BitName signing key | the chain | withhold, delay — not read, alter or forge |
| Sealed message | the same signature, inside a ratchet envelope | the chain, after the ratchet opens it | withhold — and it cannot be opened later even with the recipient's BitName key |
| Profile document | the name's signing key, and a BLAKE3 commitment on-chain | the chain | serve a stale copy or nothing |
| Endpoint record | the endpoint key it describes | itself (self-certifying) | withhold — not redirect |
| Mailbox deposit | sender's BitName | the chain, by the hub | drop mail — not write it |
| Mailbox fetch/ack | the recipient's BitName | the chain, by the hub | refuse — not read |
| Channel post | the author's BitName | the chain, by hub and reader | withhold — not invent or edit |
| Paid message | sender's BitName, inside the memo; the payment itself by the chain | the chain | nothing; it is a transaction |
4. Flows
4.1 Looking up a name
Resolution never trusts the server that answers. Kleo asks the chain (through your node) for the commitment, then hashes whatever document comes back and compares.
sequenceDiagram
autonumber
participant K as Kleo
participant N as Your node
participant H as Hub / resolver
participant S as The name's own server
K->>N: bitname_data of blake3 of bob
N-->>K: signing key, encryption key, commitment, address
Note over K: With no commitment there is no profile to fetch — keys alone still verify signatures.
K->>H: bitname_commit with bytes = commitment
H-->>K: a JSON document
K->>K: BLAKE3 of canonical JSON == commitment?
alt matches
K-->>K: Verified, source = resolver
else no answer, or a different document
K->>S: bitname_commit at the address published on-chain
S-->>K: a JSON document
K->>K: hash and compare again
alt matches
K-->>K: Verified, source = the name's own server
else
K-->>K: Unverified — shows nothing, and says every attempt it made
end
endA configured hub is asked first because it is faster and because it hides which name you looked up from that name's own server. Linked documents inside a profile ({"$commit": …}) are fetched and checked by exactly the same loop.
4.2 Registering a name
Registration is two blocks, on purpose: the first commits to a name nobody can see, the second reveals it. That is what stops a watcher from front-running a registration.
sequenceDiagram
autonumber
participant K as Kleo
participant N as Your node
participant C as Chain
K->>N: is blake3 of alice already registered?
N-->>K: no
K->>N: reserve_bitname alice
N->>C: reservation — commitment only, no plaintext
Note over C: Block n
K->>N: get_transaction_info
N-->>K: confirmed
K->>N: register_bitname alice with new signing and encryption keys
N->>C: registration — reveals the name, publishes the keys
Note over C: Block n+1
K-->>K: alice is yours — and the plaintext name was never on-chain before it wasKey rotation and profile changes use the same shape with one block: Kleo builds a
BitNameUpdate transaction, the node's wallet signs and submits it, and Kleo watches for the change to appear in the name's on-chain data.
4.3 Publishing a profile
sequenceDiagram
autonumber
participant K as Kleo
participant N as Your node
participant H as Hub
participant C as Chain
K->>K: canonical JSON (RFC 8785), commitment = BLAKE3 of it
K->>N: sign_arbitrary_msg over<br/>"kleo-resolver publish v1 / name / commitment"
N-->>K: signature
K->>H: kleo_publish [name, document, signature]
H->>H: hash the document, check it against the signed statement
H->>N: bitname_data — what key does this name publish?
Note right of H: A hub checks with its own node, not the uploader's.
H-->>K: stored, keyed by commitment
K->>N: update_bitname — set commitment
N->>C: BitNameUpdate
Note over C: One block later the chain commits to it,<br/>and every reader can verify the document.The upload is authorised by the name's own on-chain key, which is why a hub needs no accounts, passwords or quotas: only a name holder can store anything under that name.
4.4 Finding a machine
An endpoint id never changes; the address behind it changes all the time. So Kleo publishes a signed address record and dials from that.
sequenceDiagram
autonumber
participant A as Alice's Kleo
participant H as Hub (directory)
participant B as Bob's Kleo
B->>B: sign with the endpoint key:<br/>"kleo endpoint record v1 / endpoint / at / addresses / relay"
B->>H: kleo_endpoint_put [record]
H->>H: verify the signature against the endpoint id itself
H->>H: refuse a record older than the one held (no replay)
Note over H: Records expire after 24 h — a stale address is worse than none.
A->>H: kleo_endpoint_get [bob's endpoint id]
H-->>A: the signed record
A->>A: check the signature again before using it
Note over A: A hub that invents an address is simply ignored:<br/>the record wouldn't verify.
A-->>B: dial those addresses directlyIf no hub has a record, Kleo dials the bare endpoint id and lets iroh find it — today that falls back to iroh's public discovery and relays, which is the one piece of third-party infrastructure left, and the reason hubs carry this role at all.
4.5 A direct message
sequenceDiagram
autonumber
participant A as Alice's Kleo
participant AN as Alice's node
participant B as Bob's Kleo
participant BN as Bob's node
A->>AN: resolve bob — endpoints and hubs from his profile
AN-->>A: chat.endpoints, chat.hubs
A->>AN: encrypt to bob's key, signed as alice
AN-->>A: armored message
A->>B: QUIC (ALPN kleo/dm/1)<br/>4-byte length + JSON envelope
B-->>A: "ok"
Note over A,B: The envelope says from: alice.<br/>That is a claim, and nothing rests on it.
B->>BN: decrypt, then verify the inner signature
BN-->>B: signer = alice, key matches the chain
B->>B: file it as alice — flagged if anything didn't line upWhat the transport contributes is the endpoint the connection really came from. Kleo compares that with what the sender's profile publishes and says so if they differ — but a mismatch is a note, not a verdict, because the signature already settled authorship.
Attachments ride in the same envelope: the file is sealed to the recipient exactly as a
.bnenc file is, base64 inside the JSON, up to 150 KB.
4.6 A forward-secret session
A message encrypted to a name's long-lived key stays readable to anyone who later obtains that key. A prekey — an X25519 public key in the profile, so covered by the on-chain commitment — lets the two sides run a Double Ratchet instead, where each message has a key that is thrown away after it.
sequenceDiagram
autonumber
participant A as Alice's Kleo
participant AN as Alice's node
participant C as Chain
participant B as Bob's Kleo
B->>AN: publish chat.prekey in bob's profile
Note over C: The prekey is inside the profile the chain commits to,<br/>so it is as authentic as the name itself.
A->>AN: resolve bob — endpoints, hubs, prekey
AN-->>A: prekey found
A->>A: throwaway key, DH against the prekey, start the ratchet
A->>AN: sign the message as alice (unchanged)
A->>A: seal the signed armor under a one-message key
A->>B: BITNAMES SEALED MESSAGE — ratchet key, counters, ciphertext
Note over A,B: The BitName encryption key is not involved.<br/>Nothing here opens with it, now or later.
B->>B: find which prekey it was sealed to, accept the session
B->>B: open it — that message key is then discarded
B->>AN: verify the signature inside, against the chain
AN-->>B: signed by alice
B->>A: reply — a new ratchet key, and a new chain in both directionsEvery reply turns the ratchet again, so a session's current state opens what is still in flight and nothing that came before it. Messages that arrive out of order are handled by keeping their keys aside, up to a bound.
When a name publishes no prekey, Kleo falls back to encrypting to its BitName key: still private, still signed, but not forward secret — and the app says which of the two a conversation is getting.
A prekey can be replaced (Your names → New chat key), which is a profile update like any other, so Kleo asks rather than doing it on a timer: an on-chain change is the user's to spend. The key it replaces is kept for a while, because a message sealed against it may have been sitting in a mailbox for days, and the name it belonged to is kept with it — the recipient's name is bound into every sealed message, so opening one needs to know which of your names it was for. A message that opens a session Kleo does not have simply starts one, which is also what the first message from anyone looks like, and what a peer whose machine lost its state sends.
4.7 A message to someone who is offline
sequenceDiagram
autonumber
participant A as Alice's Kleo
participant AN as Alice's node
participant H as Hub (mailbox)
participant HN as Hub's node
participant B as Bob's Kleo
participant BN as Bob's node
A->>B: direct attempt
B--xA: no answer — his machine is off
A->>AN: sign "kleo mailbox deposit v1 / to / from / at / digest"
A->>H: kleo_mailbox_deposit [to, from, armored, sent_at, at, signature, attachment]
H->>HN: is alice's signature good, and is bob a registered name?
HN-->>H: yes
H-->>A: stored, id = BLAKE3 of the envelope
Note over H: The hub holds ciphertext. It knows bob has mail,<br/>from alice, how big, and when. Not a word of it.
B->>BN: sign "kleo mailbox fetch v1 / name / at"
B->>H: kleo_mailbox_fetch [bob, at, signature]
H->>HN: verify against bob's on-chain key, and refuse a stale timestamp
HN-->>H: good
H-->>B: everything waiting, oldest first
B->>BN: decrypt and verify each one
B->>H: kleo_mailbox_ack [bob, ids, at, signature]
H-->>B: forgottenKleo checks its mailboxes every 60 seconds in the background, and on demand. Deposits are deduplicated by an id both sides derive from the envelope, so a resend is not a second message.
4.8 A paid message
A paid message is not a message about a payment. It is the payment: the transfer goes to the address holding the recipient's name, and the message rides in the memo.
sequenceDiagram
autonumber
participant A as Alice's Kleo
participant AN as Alice's node
participant C as Chain
participant B as Bob's Kleo
participant BN as Bob's node
A->>AN: bitname_data of bob — paymail_fee_sats
AN-->>A: 5000 sats
A->>AN: list_utxos — which address holds bob's name?
AN-->>A: the holder's address
A->>AN: encrypt to bob, signed as alice
AN-->>A: armored message
A->>AN: create_transfer to that address,<br/>value at least the price, memo = hex of the armor
AN->>C: transaction
Note over C: Public forever: that a payment was made, and how much.<br/>Encrypted: every word of what it said.
B->>BN: get_wallet_utxos
BN-->>B: outputs, including one with a memo
B->>BN: hex-decode the memo, decrypt, verify
BN-->>B: from alice, verified, 5000 sats
B->>B: file it, noted with what was paidA free message from a stranger to a name that charges is dropped at the receiving end, and refused at the sending end with the price — because the price is on-chain, the sender always knows it before writing. Contacts, anyone you have replied to, and names on your allow list are never charged.
4.9 A channel post
A channel is itself a BitName, so its rules are in its profile, under the same on-chain commitment as everything else there.
sequenceDiagram
autonumber
participant A as Author's Kleo
participant AN as Author's node
participant H as Hub (channels)
participant HN as Hub's node
participant R as Reader's Kleo
participant RN as Reader's node
A->>AN: resolve the channel — kind, authors, moderators, bans
A->>AN: sign "kleo channel post v1 / channel / author / at" + text
A->>H: kleo_channel_post [post]
H->>HN: resolve the channel profile — does it allow this author?
H->>HN: verify the signature against the author's on-chain key
HN-->>H: both good
H-->>A: stored
R->>RN: resolve the channel (same profile, same rules)
R->>H: kleo_channel_posts [channel, limit]
H-->>R: posts, newest first
loop every post
R->>R: id matches its contents?
R->>R: channel allows this author?
R->>RN: signature good against the chain?
end
R-->>R: verified posts shown, anything else shown as unverified with the reasonThe hub checks so that it does not become a dumping ground; the reader checks so that the hub does not have to be trusted. Both, independently. A ban is published in the channel's profile, so every hub and every reader applies the same one — a hub cannot quietly decide who may speak, and cannot fail to apply a ban the channel declared.
4.10 A private group's key
sequenceDiagram
autonumber
participant O as Owner's Kleo
participant ON as Owner's node
participant C as Chain
participant M as Member's Kleo
participant H as Hub
O->>O: generate a 32-byte group key
loop each member
O->>ON: encrypt_msg the key to that member's on-chain encryption key
ON-->>O: sealed copy
end
O->>ON: publish the profile: members, sealed keys, epoch + 1
ON->>C: commitment
Note over C: The sealed copies are public. Only the member they were<br/>sealed to can open theirs.
M->>M: read the channel profile, take its own sealed copy
M->>ON: decrypt_msg with its own encryption key
M->>M: group key
M->>H: post — text encrypted under the group key, then signed
Note over H: The hub stores ciphertext and cannot read a line.
Note over O,M: Removing someone means a new key, sealed only to who is left,<br/>and epoch + 1 — they read nothing written afterwards.5. The hub protocol
A hub speaks JSON-RPC 2.0 on POST / and serves a small read-only HTTP API. Everything that changes state is authorised by a signature over a short, human-readable statement — so a hub operator reading their own logs can see exactly what each signature allowed.
Methods
| Method | Params | Signed by | Notes |
|---|---|---|---|
kleo_hub_info | — | — | roles, relay URL, retention, limits, the hub's own BitName |
kleo_resolver_info | — | — | the address name holders point at on-chain |
bitname_commit | [bytes] — the commitment, hex | — | upstream's protocol. With --single NAME, a plain call answers for that name |
kleo_publish | [name, document, signature] | the name | stores a profile, keyed by commitment |
kleo_mailbox_deposit | [to, from, armored, sent_at_ms, at_ms, signature, attachment?] | the sender | returns the envelope id |
kleo_mailbox_fetch | [name, at_ms, signature] | the recipient | everything waiting, oldest first. name may be hash:<64 hex> |
kleo_mailbox_ack | [name, ids, at_ms, signature] | the recipient | returns how many were forgotten |
kleo_endpoint_put | [record] | the endpoint key | refuses stale or replayed records |
kleo_endpoint_get | [endpoint_id] | — | the record, for the caller to check |
kleo_channel_post | [post] | the author | checked against the channel's published rules |
kleo_channel_posts | [channel, limit] | — | newest first |
What gets signed
kleo-resolver publish v1
name: alice
commitment: <64 hex>
kleo mailbox deposit v1
to: bob
from: alice
at: 1737000000000
envelope: <BLAKE3 of the armor and any attachment, 64 hex>
kleo mailbox fetch v1
name: bob
at: 1737000000000
kleo mailbox ack v1
name: bob
at: 1737000000000
ids: a1b2…,c3d4…
kleo endpoint record v1
endpoint: <endpoint id>
at: 1737000000000
addresses: 198.51.100.7:4242,203.0.113.9:4242
relay: https://relay.example
kleo channel post v1
channel: releases
author: alice
at: 1737000000000
0.2 is outNaming a mailbox by its hash. A wallet restored from its recovery phrase knows the hashes of the names it holds — that is all the chain stores — but not the words behind them. Fetching and acknowledging therefore accept hash:<64 hex> in place of a name: the hub files mail under the hash either way, and checks the signature against the key that hash publishes. The prefix makes it unambiguous, even for a name that happens to look like a hash. So a restored Kleo collects its own mail before anybody retypes anything.
Every statement names its subject, so a signature cannot be lifted from one context into another: a deposit for bob cannot be redirected to carol, and a post signed for one channel cannot appear in another. Timestamps must be within 10 minutes of the hub's clock, which makes a captured request worthless once it goes stale.
HTTP API
| Route | Returns |
|---|---|
GET /health | ok |
GET /v1/names/{name} | the name's keys and verified profile, resolved by the hub's own node |
GET /v1/names/{name}/pay | its published payment methods |
GET /v1/commitments/{hex} | a document by its hash, as canonical JSON |
Roles
A hub advertises which of these it runs, and each can be switched off:
resolver · mailbox · directory · channels · relay
kleo-hub --node http://127.0.0.1:6002 --listen 0.0.0.0:7777 \
--public-addr 203.0.113.7:7777 --hub-name myhub \
--retention-days 30 --relay-url https://relay.exampleA hub with --hub-name publishes its own profile, so it can be looked up and pinned exactly like a person — and Kleo can say "this is not the hub you used last time" when its keys change.
6. Limits and retention
| Thing | Limit | Where it is set |
|---|---|---|
| Message envelope | 256 KiB | kleo_core::chat::MAX_ENVELOPE_BYTES |
| Attachment in a message | 150 KiB | kleo_core::chat::MAX_ATTACHMENT_BYTES |
| Paid message (memo) | 4 KiB armor (the chain took 16 KiB in testing) | kleo_core::paymail::MAX_MESSAGE_BYTES |
| Mail per name, per hub | 256 envelopes, then deposits are refused rather than dropped | kleo_core::hub::MAX_ENVELOPES_PER_NAME |
| Mail retention | 30 days, published in the hub descriptor | --retention-days |
| Endpoint record age | 24 hours | ENDPOINT_MAX_AGE_HOURS |
| Signed request skew | ±10 minutes | kleo_core::hub::MAX_CLOCK_SKEW_MS |
| Channel post | 4 KiB, 500 posts per channel per hub | kleo_core::channel |
| Profile document | 256 KiB | kleo_core::profile::MAX_DOCUMENT_BYTES |
| Endpoints per name | 4 devices | kleo_core::channel::MAX_ENDPOINTS |
| File (sign / encrypt) | 256 MiB | kleo_core::files::MAX_FILE_BYTES |
7. What happens when something is missing
| If this is down or lying | What stops | What still works |
|---|---|---|
| Your node | everything: no keys, no verification, no sending | nothing — this is the one piece that must be honest |
| Every hub | mail for offline people, address lookups, channel reads | direct messages to people who are online, all workbench and file work, the wallet |
| One hub of several | nothing; Kleo tries each in turn | everything |
| A hub serves an altered profile | nothing — the hash won't match the chain | Kleo says it couldn't verify, and names every attempt |
| A hub invents a channel post | nothing — the signature fails at the reader | the real posts, shown as verified |
| A hub withholds mail | that mail, silently — a missing message looks like no message | everything else |
| iroh's relays | reaching peers behind strict NATs without a hub record | direct links on the same network, hubs, mail |
| The sidechain stalls | new names, profile changes, payments | reading, verifying and messaging with what is already on-chain |
The asymmetry is deliberate: infrastructure can make Kleo less useful at any time, and can never make it wrong.