How Kleo works
Kleo's whole idea is that you address people by name and never touch a key.
alice is not a label for a key you have to fetch, compare and trust — it is a record on a chain, and the keys come from there every time.
This is what that costs and what it buys: the primitives, where each one runs, and the flow of a message from one person to another.
What a name is
A BitName is blake3(name). The plaintext never goes on the chain, so the chain holds a hash, a sequence id, and whatever the holder published against it — a signing key, an encryption key, an address, a commitment to a profile.
Nothing normalises the name: Alice and alice are different BitNames.
flowchart LR
N["alice"] -->|blake3| H["3aa557…c2e80c"]
H --> C[("the chain")]
C --> K["signing key bn-svk1…<br/>encryption key bn-enc1…<br/>commitment blake3 of a profile"]The primitives
Kleo adds no cryptography of its own. Signing and encryption happen inside the BitNames node, over an RPC that never leaves loopback, because that RPC exposes wallet secrets.
| What | Algorithm | Where it runs |
|---|---|---|
| Name to identifier | BLAKE3 | anywhere — it is a hash |
| Signing, verifying | Ed25519, over [dst] ‖ message | the node's wallet |
| Encrypting to a name | ECIES: X25519 + AES-256-GCM + HMAC-SHA256 | the node's wallet |
| Files | XChaCha20-Poly1305 per file, its key sealed to the name | Kleo, then the node |
| Forward secrecy | Double Ratchet: X25519, HKDF-SHA256, XChaCha20-Poly1305 | Kleo |
| Profile commitment | BLAKE3 over RFC 8785 canonical JSON | Kleo |
| Everything stored locally | XChaCha20-Poly1305, key in the OS keychain | Kleo |
| Backups and the app lock | Argon2id (64 MiB, t=3, p=1) then XChaCha20-Poly1305 | Kleo |
The domain separator matters more than it looks. The node signs
[dst] ‖ message, where dst is Transaction (0x00) or Arbitrary (0xFF). A signature over a message can therefore never be replayed as a signature over a transaction, which is what makes it safe to sign arbitrary text with the same key that moves coins.
Resolving a name
Every operation starts here, and the answer is only worth having because it is checked rather than believed.
sequenceDiagram
participant K as Kleo
participant N as your node
participant H as a hub
K->>N: bitname_data(blake3 of the name)
N-->>K: keys, seq id, commitment
K->>H: GET /v1/names/alice
H-->>K: the profile document
K->>K: blake3 of the canonical JSON
alt matches the commitment on chain
K-->>K: verified
else does not match
K-->>K: unverified, and says so
endA hub can withhold a profile or serve a stale one. It cannot forge one: the commitment on the chain is what the document is measured against, and only the name's holder can change that.
Signing and verifying
sequenceDiagram participant A as alice's Kleo participant NA as alice's node participant B as bob's Kleo participant NB as bob's node A->>NA: sign_arbitrary_msg(text) NA-->>A: Ed25519 signature A->>B: armoured text plus signature plus the claimed name B->>NB: bitname_data(alice) NB-->>B: alice's current signing key B->>NB: verify_signature(sig, key, arbitrary, text) NB-->>B: valid or not B->>B: judge it
That last step is the one that matters. A valid signature only says some key signed it. Kleo reports whether the key is the one alice publishes now, whether it is a key you had saved for her before, and whether the name is claimed at all — so a rotated key reads as "keys changed", not as a silent success.
Encrypting to a name, and to a file
Text goes straight to the node's ECIES. A file cannot: the RPC takes text, so every byte would have to be base64'd through it.
sequenceDiagram participant K as Kleo participant N as your node Note over K: a file K->>K: random XChaCha20-Poly1305 key K->>K: encrypt the bytes with it K->>N: encrypt_msg(bob's key, the file key) N-->>K: the sealed file key K->>K: armour - sealed key plus ciphertext plus BLAKE3 of the plaintext
The recipient asks their node to open the file key, then opens the file locally. The big data never crosses the RPC.
A message
Two people, each with a node they trust and a Kleo that holds no keys of its own worth stealing.
sequenceDiagram
participant A as alice's Kleo
participant NA as alice's node
participant B as bob's Kleo
participant H as a hub
A->>NA: resolve bob - endpoint, prekey, hubs
NA-->>A: from the chain and the profile
A->>A: seal to bob and sign as alice
alt bob's machine answers
A->>B: straight there, over QUIC
else bob is offline
A->>H: deposit the sealed envelope
Note over H: holds it, cannot read it
B->>H: fetch, when bob opens Kleo
H-->>B: the same envelope
end
B->>B: open it
B->>B: verify the signature against alice's chain keyThe hub sees who left mail for whom and when. It cannot read a word of it, and it cannot put words in anyone's mouth: the envelope is sealed to bob before it arrives and signed by alice before it is sealed.
Forward secrecy
Once both sides publish a prekey in their profile, messages stop using the long-term encryption key and use a Double Ratchet instead. Stealing today's keys then does not open yesterday's messages.
sequenceDiagram participant A as alice participant B as bob Note over B: prekey published in bob's profile A->>A: new ephemeral key, DH against bob's prekey A->>A: HKDF-SHA256 gives a root and a chain A->>B: header - ratchet key, ephemeral, prekey it was sealed to B->>B: DH with the prekey's secret, same root B->>B: open it B->>A: reply, with a ratchet key of its own Note over A,B: every message steps the chain, every reply steps the root
Two details are load-bearing, and each one broke something when it was not.
The sealed message is authenticated against the pair of names it is between — kleo conversation v1 ‖ from ‖ to — along with the header's ratchet key, counters and ephemeral. So a message cannot be replayed into a different conversation, and a recipient has to know which of their names it was for before they can open it.
And only the side that starts a session echoes the prekey it started against. A session made by accepting one has none to echo, so every reply arrives without that hint. Taking the recipient's name from the header works in one direction and silently fails in the other.
What is stored, and where
flowchart TB
subgraph chain["the chain"]
C1["name hash, keys, commitment"]
end
subgraph node["your node - loopback only"]
N1["wallet: signing and encryption secrets"]
N2["coins, names you hold"]
end
subgraph kleo["Kleo - encrypted with a key in the OS keychain"]
K1["settings, contacts"]
K2["message history"]
K3["ratchet prekeys and sessions"]
K4["the chat endpoint key"]
end
subgraph hub["a hub - holds nothing it can read"]
H1["profiles, already signed"]
H2["sealed mail"]
H3["endpoint records, signed by the endpoint"]
endThe split is deliberate: the node holds the identity, Kleo holds the conversation. Lose the node's seed phrase and the name is gone. Lose Kleo's directory and you keep the name but lose the messages. They are backed up separately because they fail separately.
Who has to be trusted
Nobody, in the sense that matters, and this is checkable rather than promised:
- A hub holds signed and sealed things. It can withhold, delay, and see
who talks to whom. It cannot read, forge or alter.
- A mainchain provider, if you borrow one instead of running
bitcoind
yourself, can lie to you about Bitcoin — deposits, withdrawals. It holds no key and can sign nothing.
- Your node is the one thing that must be yours. It holds the secrets,
and its RPC stays on loopback for exactly that reason.
The longer version, including what Kleo does not protect, is in the threat model. The protocol itself, message by message, is in the network protocol.
What Kleo writes, and where
Everything is plain text or a small binary file next to the original. Nothing is locked in a database you cannot read.
| Artifact | Shape | Notes |
|---|---|---|
| Signed message | -----BEGIN BITNAMES SIGNED MESSAGE----- | Text, then a signature block with Signer and Key headers. |
| Encrypted message | -----BEGIN BITNAMES ENCRYPTED MESSAGE----- | ECIES over X25519 with AES-256-GCM, done by the node. |
| File signature | <file>.bnsig | Detached. Covers the file's BLAKE3 hash, not its name. |
| Encrypted file | <file>.bnenc | A one-off key per file, sealed to the recipient's BitName. |
| Checksums | <name>.b3sums | BLAKE3, in the conventional hash name layout. |
| Contacts and chat history | XChaCha20-Poly1305 vault | Key in the OS keychain. Nothing readable on disk. |
| Profile document | JSON plus an on-chain commitment | RFC 8785 canonical JSON hashed with BLAKE3, served by any host. |
Good to know
- Double-click a name anywhere — Lookup, Contacts, a workbench field, a verification result — to open its card.
- Signing keys are
bn-svk1…, encryption keysbn-enc1…, both bech32m. - A BitName on-chain is
blake3(name), and the plaintext name never touches the chain. - Rotating keys is a
BitNameUpdatetransaction. plain-bitnames has no RPC for it, so Kleo builds the transaction and has the node's wallet sign and submit it.