KKleo

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 --> chain

Kleo 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.

PortWho listensWhat for
6002plain-bitnamesJSON-RPC. Wallet methods live on the private address; keep it on loopback
4002plain-bitnamesthe sidechain's own peer-to-peer network
7777kleo-hubHTTP + JSON-RPC: profiles, mail, endpoint records, channel posts
ephemeralKleoiroh QUIC, ALPN kleo/dm/1, for direct messages

2. Addressing: names, keys, endpoints

ThingShapeWhere it livesChanges
BitNamealicethe chain stores blake3(alice), never the textnever (the name is the identity)
Signing keybn-svk1… bech32mon-chain, in the name's datawhen the owner rotates it
Encryption keybn-enc1… bech32mon-chain, in the name's datawhen the owner rotates it
Profile commitment32-byte BLAKE3on-chainon every profile change (one block)
Profile documentcanonical JSON (RFC 8785)a hub, or the name's own serverfreely; the commitment is what pins it
Chat endpointiroh endpoint id — a public keyin the profile, under chat.endpoint / chat.endpointsrarely (per device)
PrekeyX25519 public keyin the profile, under chat.prekeywhen you rotate it; sessions already running are unaffected
Reachable addressip:port, plus a relay URLa hub's endpoint directory, signed by the endpoint keyconstantly
Payment addressbase58, 20 bytes, no checksumon-chain; the address holding the name receives paymailper 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.
ArtifactSigned byChecked againstA dishonest carrier can
Message / filesender's BitName signing keythe chainwithhold, delay — not read, alter or forge
Sealed messagethe same signature, inside a ratchet envelopethe chain, after the ratchet opens itwithhold — and it cannot be opened later even with the recipient's BitName key
Profile documentthe name's signing key, and a BLAKE3 commitment on-chainthe chainserve a stale copy or nothing
Endpoint recordthe endpoint key it describesitself (self-certifying)withhold — not redirect
Mailbox depositsender's BitNamethe chain, by the hubdrop mail — not write it
Mailbox fetch/ackthe recipient's BitNamethe chain, by the hubrefuse — not read
Channel postthe author's BitNamethe chain, by hub and readerwithhold — not invent or edit
Paid messagesender's BitName, inside the memo; the payment itself by the chainthe chainnothing; 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
    end

A 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 was

Key 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 directly

If 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 up

What 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 directions

Every 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: forgotten

Kleo 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 paid

A 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 reason

The 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

MethodParamsSigned byNotes
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 namestores a profile, keyed by commitment
kleo_mailbox_deposit[to, from, armored, sent_at_ms, at_ms, signature, attachment?]the senderreturns the envelope id
kleo_mailbox_fetch[name, at_ms, signature]the recipienteverything waiting, oldest first. name may be hash:<64 hex>
kleo_mailbox_ack[name, ids, at_ms, signature]the recipientreturns how many were forgotten
kleo_endpoint_put[record]the endpoint keyrefuses stale or replayed records
kleo_endpoint_get[endpoint_id]—the record, for the caller to check
kleo_channel_post[post]the authorchecked 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 out

Naming 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

RouteReturns
GET /healthok
GET /v1/names/{name}the name's keys and verified profile, resolved by the hub's own node
GET /v1/names/{name}/payits 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.example

A 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

ThingLimitWhere it is set
Message envelope256 KiBkleo_core::chat::MAX_ENVELOPE_BYTES
Attachment in a message150 KiBkleo_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 hub256 envelopes, then deposits are refused rather than droppedkleo_core::hub::MAX_ENVELOPES_PER_NAME
Mail retention30 days, published in the hub descriptor--retention-days
Endpoint record age24 hoursENDPOINT_MAX_AGE_HOURS
Signed request skew±10 minuteskleo_core::hub::MAX_CLOCK_SKEW_MS
Channel post4 KiB, 500 posts per channel per hubkleo_core::channel
Profile document256 KiBkleo_core::profile::MAX_DOCUMENT_BYTES
Endpoints per name4 deviceskleo_core::channel::MAX_ENDPOINTS
File (sign / encrypt)256 MiBkleo_core::files::MAX_FILE_BYTES

7. What happens when something is missing

If this is down or lyingWhat stopsWhat still works
Your nodeeverything: no keys, no verification, no sendingnothing — this is the one piece that must be honest
Every hubmail for offline people, address lookups, channel readsdirect messages to people who are online, all workbench and file work, the wallet
One hub of severalnothing; Kleo tries each in turneverything
A hub serves an altered profilenothing — the hash won't match the chainKleo says it couldn't verify, and names every attempt
A hub invents a channel postnothing — the signature fails at the readerthe real posts, shown as verified
A hub withholds mailthat mail, silently — a missing message looks like no messageeverything else
iroh's relaysreaching peers behind strict NATs without a hub recorddirect links on the same network, hubs, mail
The sidechain stallsnew names, profile changes, paymentsreading, 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.