Appearance
Node Identity, Encryption and Pairing
Every Clusto node has one persistent identity: an Ed25519 key pair created the first time the daemon starts. The app, the CLI and the other nodes of a cluster pin that key, so they know they are talking to the node they meant to reach before they send it a token. Every native connection is encrypted with it.
This page explains what is encrypted, how a client checks a node on first contact, which networks may still use unencrypted connections, how pairing codes replace the old user secret, and what to do when an identity changes.
The node identity
- The identity is the node's public key, shown as 64 lowercase hex characters. The app and CLI also show a short fingerprint, the first 16 characters in groups of four (
abcd-ef01-2345-6789). - The private half lives in
identity.keyin the daemon's data directory, next to the database andclustod.log(for the Linux service that is/root/.local/share/clusto/identity.key). It is created with owner-only permissions, never written toconfig.tomland never replicated to other nodes. - It is the same key as the node's Clusto Tunnel endpoint id. A node that already had a tunnel key keeps it: the daemon moves
[tunnel] secret_keyintoidentity.keyon its first start of this release, so the endpoint id and every saved tunnel route stay valid. clusto identity showprints the identity and fingerprint of the connected node. The publicGET /identifyendpoint also reports it.
Back it up with config.toml. If the data directory is lost or the node is reinstalled without identity.key, the node gets a new identity and everything that pinned the old one will notice (see When an identity changes). Do not copy one node's identity.key to another node: two nodes would then claim the same identity. If a crash during the very first start left a file named identity.key.<something>.tmp in the data directory, it is safe to delete.
What is encrypted where
| Connection | Encryption |
|---|---|
| Desktop and mobile app to a node | TLS 1.3 with the node's raw public key, pinned to its identity. Works with or without a certificate and with a bare IP address |
clusto CLI to a node | The same raw-key TLS. The CLI only uses plain ws:// for a loopback host; --tls (CLUSTO_TLS) forces TLS for loopback too |
| Node to node (peer port 9179) | TLS 1.3 only, with both nodes authenticating by their keys. A plaintext connection to the peer port is answered with 426 Upgrade Required |
| Browser to a node | wss:// with the node's certificate (tls_cert and tls_key, for example a Tailscale certificate), or the web relay. Browsers cannot pin a raw key |
| Any client over Clusto Tunnel | End-to-end encrypted and key-authenticated by the tunnel itself |
| Helpers inside a session | Plain WebSocket on a loopback-only listener of the same machine |
Because every node always serves encrypted connections and accepts credentials over plaintext only from trusted networks (see below), the default bind = "0.0.0.0" is safe.
The native app and the CLI never send a token over an unencrypted connection to a non-loopback host. The web app is the one client that cannot pin a key. On an https page it only uses wss:// or the relay; on a plain http page (a development build) it asks you, per host, before it connects over unencrypted ws://.
First contact: checking a node before sending a token
A client that has no pin for a node, or whose pin no longer matches, does not just send its token:
- It asks the node to prove its identity. The node answers with a keyed hash of the client's random challenge, made with its token, bound to the key it presented. Only a node that really holds your token can produce it.
- If the proof checks out, the client sends the token and records the identity: in the saved connection profile for the app, in
known_nodes.toml(next toprofiles.toml, keyed byhost:port) for the CLI. - If it does not check out, nothing is sent.
A pairing link (below) or --identity <hex> (CLUSTO_IDENTITY) pins the identity up front, so even the first connection is verified against a key you got through another channel. Inside a session the daemon sets CLUSTO_DAEMON_IDENTITY for the CLI, so in-session helpers are pinned to their own node.
clusto identity list shows the identities the CLI remembers and clusto identity forget <host:port> drops one, so the next connection verifies the node again.
Unencrypted connections
A node's client port also accepts plain HTTP, but it refuses to use it for anything that carries a credential unless the source is trusted. These routes are refused over plaintext: the WebSocket (/ws), /notifications, /metrics and the client downloads (/clients/...). /identify and /health stay public so discovery and health checks keep working.
Plaintext is allowed from:
- Loopback (this machine).
- Tailscale: both the remote and the local address must be in Tailscale's ranges (
100.64.0.0/10,fd7a:115c:a1e0::/48). A LAN that merely uses carrier-grade NAT space is not treated as Tailscale. - Networks you list in
[transport] plaintext_networksinconfig.toml. The list takes CIDR ranges or single addresses, IPv4 or IPv6 (including IPv4-mapped forms), and is reloaded without a restart. Invalid entries are logged and skipped.
Anything else gets 403 with the text "This Clusto node refuses unencrypted connections from ...", before any token is sent. Fix it by connecting with the app or CLI (always encrypted), over Tailscale or the tunnel, with https and the node's certificate, or by listing the network:
toml
[transport]
plaintext_networks = ["192.168.1.0/24", "10.0.0.5"]The refusal is controlled by the transport.refuse_lan_plaintext release flag. It ships off: a plaintext credential route from another network is served and logged as a warning in the daemon log, so apps on older builds can still update. Once the fleet has updated, the flag is published on and those requests get 403. Turn it on early for one node with [features] "transport.refuse_lan_plaintext" = true; see Feature Flags. The flag is removed in 1.16, after which plaintext_networks is the only switch.
Things this affects: a Prometheus scrape of /metrics over a LAN address, a script that opens ws:// to a node, and the Android background notification poll. Scrape over Tailscale, loopback or https, or list the scraper's network.
Pairing codes
A pairing code lets the app, or another node, take over a node it has not met without anyone copying the node token. It replaces the old user_secret.
There are two kinds:
| Kind | Format | Defaults |
|---|---|---|
| Generated code | XXXXX-XXXXX-XXXXX-XXXXX (20 characters, 100 random bits; 0-9 and A-Z without U) | Single use, expires in 24 hours |
| Custom password | 16 to 256 characters you choose | Reusable, never expires |
You can set an expiry (1 hour, 24 hours, 7 days, or never) and choose single use or reusable for either kind.
- The code is shown once, when you create it. The node keeps only a slow-to-guess verifier (PBKDF2-HMAC-SHA256, 600,000 iterations), never the code or the password, and the secret itself never crosses the network: client and node prove to each other that they know it.
- A single-use code is consumed by its first successful pairing.
- Whoever uses a code gets full control of the node and its cluster: a successful pairing returns the node token and the cluster token. Treat a code like a token. Prefer a generated code or a pairing link, and keep custom passwords long. A custom password is only as strong as you make it.
- Ten wrong attempts within 15 minutes lock pairing for the whole node (including attempts through the tunnel or relay) for 15 minutes. Each further lockout doubles the wait, up to 24 hours. Setting a new code or password clears the lock. Logging in with the token is not affected.
Creating a code
- App: Cluster > the node's Configure Node > Pairing code. Generate a code, or set a custom password. Copy the code or the pairing link.
- CLI:
clusto pairing generate,clusto pairing set,clusto pairing status,clusto pairing clear. See CLI Reference. - Installer:
sudo clusto installoffers to create a code, andclusto install --pairing-codeprints one once. Only the verifier is written toconfig.toml.
config.toml stores the result under [pairing]. That verifier is as good as the password for anyone who can read it, so keep the file owner-only (it already holds the node token).
A node that still had a user_secret upgrades automatically: a secret of 16 or more characters becomes a custom password, and the plaintext is removed from the file. A shorter one is disabled; pairing then reports "reset required" until you set a new code.
The pairing link
A generated code comes with a link such as clusto://pair?v=1&node=...&id=<identity>&name=...&addr=<host:port>&code=.... It carries the node's identity, its addresses and the code, so opening it pairs with a verified node on the first try: on desktop and Android it opens the app, and you can also paste it into the Connect or Adopt Node dialogs. A custom password is never put in a link.
On an untrusted network, use the link or a generated code. If you type a custom password at a node you have not pinned, an attacker who sits in the middle of that first connection could collect enough to guess a weak password offline.
Using a code
- Connect dialog: enter the code in the credential field. A credential that could be either a password or a hand-set token asks "Use it as: Pairing code or Token". A pairing code is exchanged for the token and never sent; a token is sent only after the node proves it already knows it.
- Adopt Node: enter the remote node's address and its code (or token) under Pairing code or token. With a link, the node identity is filled in and the connection is pinned to it.
Adopting a node
The node you adopt from performs the connection itself. It dials the remote node over key-pinned TLS and never sends a credential, or its cluster token, to a node it has not verified. After a successful pairing or token check it hands the new node the cluster token and the full list of nodes with their identities, so the new node pins every peer from its first connection. See Clustering.
When an identity changes
A node presents a different key after a reinstall, a restore without identity.key, or a lost data directory. It can also mean someone is intercepting the connection. What you see depends on the client.
App and CLI (clients). If the node can still prove it holds your token, the new identity is accepted: the app shows a warning toast ("has a new identity ... verified with your token") and re-pins, the CLI prints a warning: line. If it cannot prove it, nothing is sent:
- The app shows Node identity changed and stops reconnecting. Pair again with a pairing code, or update the token and use Edit profile. If another route (a tunnel, say) connected, the dialog still warns about the refused route.
- The CLI refuses with "presented identity ..., but ... is recorded in known_nodes.toml, and the node could not prove it knows your token". After a genuine reinstall, update your token or run
clusto identity forget <host:port>.
Other nodes in the cluster. A peer whose key does not match its pin is refused, and it shows as offline with an Identity changed badge on its card in Cluster Status. After a reinstall you did on purpose, choose Trust new identity on that card and confirm. Every node then accepts the new key and the peer reconnects. Otherwise leave it refused and find out why the key changed.
Anyone who holds the cluster token is a full cluster member and can change a pin for the whole cluster. Keep the cluster token as private as the node tokens.
A node that was reinstalled and removed from the cluster can also be adopted again with a pairing code.
Writing your own client
Most WebSocket libraries cannot do raw-key TLS. For a script or a third-party client, use one of these:
- the loopback address of the node's machine;
- Tailscale;
https/wsswith the node's certificate (tls_certandtls_key);- a network you list in
plaintext_networks.
The handshake messages (identity proof and pairing) are documented in the Protocol reference. Client and node must run the same Clusto release: there is no mixed-version operation.