KKleo

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.

WhatAlgorithmWhere it runs
Name to identifierBLAKE3anywhere — it is a hash
Signing, verifyingEd25519, over [dst] ‖ messagethe node's wallet
Encrypting to a nameECIES: X25519 + AES-256-GCM + HMAC-SHA256the node's wallet
FilesXChaCha20-Poly1305 per file, its key sealed to the nameKleo, then the node
Forward secrecyDouble Ratchet: X25519, HKDF-SHA256, XChaCha20-Poly1305Kleo
Profile commitmentBLAKE3 over RFC 8785 canonical JSONKleo
Everything stored locallyXChaCha20-Poly1305, key in the OS keychainKleo
Backups and the app lockArgon2id (64 MiB, t=3, p=1) then XChaCha20-Poly1305Kleo

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
  end

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

The 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"]
  end

The 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:

who talks to whom. It cannot read, forge or alter.

yourself, can lie to you about Bitcoin — deposits, withdrawals. It holds no key and can sign nothing.

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.

ArtifactShapeNotes
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>.bnsigDetached. Covers the file's BLAKE3 hash, not its name.
Encrypted file<file>.bnencA one-off key per file, sealed to the recipient's BitName.
Checksums<name>.b3sumsBLAKE3, in the conventional hash name layout.
Contacts and chat historyXChaCha20-Poly1305 vaultKey in the OS keychain. Nothing readable on disk.
Profile documentJSON plus an on-chain commitmentRFC 8785 canonical JSON hashed with BLAKE3, served by any host.

Good to know