Understanding the Tailcat Key Management Model: A Control-Plane-Free Approach
Tailcat implements a decentralized key management model where each node generates its own cryptographic identity (node key) and derives a separate discovery key (disco key), exchanging public keys via compact ConnBlob tokens without requiring a centralized control plane.
The Tailcat key management model powers peer-to-peer encrypted connections in the tailscale/tailcat repository by eliminating traditional certificate authorities. Instead of relying on external identity providers, each endpoint maintains autonomous control over its cryptographic material, enabling fully stateless operation in user space.
Core Cryptographic Primitives
Tailcat builds its security model on two distinct key pairs that serve separate purposes in the connection lifecycle.
Node Keys for Authentication
Every Tailcat instance owns a node private key (key.NodePrivate) and corresponding node public key (key.NodePublic). According to the source code in tailcat.go, the server can accept an explicit key via the Server.Key field; if left uninitialized, the system generates a fresh ephemeral key on startup.
// Server side – generate a new key if none provided, then start
s := &tailcat.Server{}
if err := s.Start(); err != nil { log.Fatal(err) }
blob := s.ConnBlob() // token containing public keys to give to clients
Disco Keys for Path Discovery
The disco key (discovery key) operates independently from the node key but is cryptographically derived from it. In tailcat.go, the function discoPrivateForNode deterministically generates the disco private key from the node private key. This design ensures that the disco key remains safe to expose on the wire while keeping the node key private.
Disco keys seal "call-me-maybe" packets that advertise UDP endpoints, enabling NAT traversal without exposing the sensitive node key material.
Key Generation and Derivation
The NewPrivateKey helper function in tailcat.go (lines 33-40) orchestrates the creation of both key pairs. This function generates a new node key using key.NewNode() and immediately derives the disco key from it.
// Create new identity
pk := tailcat.NewPrivateKey()
// pk.Private contains key.NodePrivate
// pk.Public.ServerPublic contains key.NodePublic
// pk.Public.ServerDiscoPublic contains the derived disco public key
The function returns a PrivateKey struct containing the private key material and a populated ConnInfo object that carries the public components.
Public Key Distribution via ConnBlob
Tailcat eliminates the need for a coordination server by embedding public keys directly into connection tokens. The ConnInfo struct (defined in tailcat.go lines 44-51) exposes two critical fields:
ServerPublic– the node public key for WireGuard authenticationServerDiscoPublic– the disco public key for endpoint discovery
When a server starts, the connBlob() method encodes this information into a ConnBlob—a self-contained token that includes the public keys and DERP region data. Clients parse this blob to recover the server's cryptographic identity without network round-trips to a control plane.
Access Control with Allowed Clients
The Tailcat key management model supports fine-grained access control through allow-listing. The Server type maintains an AllowedClients field—a slice of key.NodePublic values that restricts which client identities may connect.
// Restrict clients to a specific node public key
allowedKey := tailcat.NewPrivateKey().Public.ServerPublic
s.AddAllowedClient(allowedKey.NodePublic)
The backend stores these authorized keys in an internal map (allowedClients) and validates incoming connections in the onMeow handler. New clients can be added dynamically at runtime via Server.AddAllowedClient, allowing for flexible key rotation without restarting the server.
Ephemeral vs. Persistent Key Lifecycle
Tailcat defaults to ephemeral keys that exist only in memory. The source code explicitly avoids built-in persistence:
- Server lifecycle: On
Start, the server uses the suppliedServer.Keyor generates a fresh ephemeral key viakey.NewNode(). The public key becomes part of theConnBlobdistributed to clients. - Client lifecycle: On first use (
nodeKeyLocked), the client either uses a supplied private key or generates an ephemeral one, then parses the server'sConnBlobto obtain the server's public keys.
For scenarios requiring persistent identities, the PrivateKey struct (lines 22-28 in tailcat.go) exposes the raw key.NodePrivate field, allowing users to serialize and store keys manually using json.Marshal. The library deliberately provides no built-in key store, maintaining stateless operation by default.
Source Code Implementation
The key management logic resides in specific files within the repository:
tailcat.go– Core library containingPrivateKeystruct,ServerandClienttypes,NewPrivateKeygeneration, andConnBlobhandlingwire.go– CBOR wire format for encoding/decodingConnInfoandConnBlobstructurestailcat_ssh.go– SSH host-key handling (separate from the WireGuard node key model)client_test.go– Verification tests for key creation, serialization, and allowed-client enforcement
Summary
- The Tailcat key management model uses node keys for WireGuard authentication and disco keys for safe public endpoint advertisement.
- Keys are generated locally via
NewPrivateKeyintailcat.go, with disco keys derived deterministically from node keys usingdiscoPrivateForNode. - Public keys travel via ConnBlob tokens rather than control plane lookups, enabling fully peer-to-peer connections.
- Access control operates through
AllowedClientschecks againstkey.NodePublicidentities, with runtime updates viaAddAllowedClient. - By default, keys remain ephemeral and memory-resident; persistence requires manual handling of the
PrivateKeystruct.
Frequently Asked Questions
How does Tailcat handle key storage and persistence?
Tailcat does not persist keys to disk by default. The PrivateKey struct in tailcat.go exposes the raw Private field as a key.NodePrivate type, which users can manually serialize (e.g., via json.Marshal) when persistent identities are required. This design keeps the library stateless and eliminates filesystem dependencies.
What is the difference between node keys and disco keys in Tailcat?
Node keys (key.NodePrivate/key.NodePublic) authenticate WireGuard connections and must remain confidential. Disco keys are derived from node keys but are safe to transmit openly; they encrypt UDP endpoint advertisements for NAT traversal. This separation allows public endpoint discovery without exposing the long-term authentication key.
How does Tailcat authenticate clients without a control plane?
Tailcat validates client identities against an explicit allow-list. The Server type stores permitted key.NodePublic values in AllowedClients and checks incoming connections in the onMeow handler. Because public keys are exchanged offline via ConnBlob tokens, no online certificate authority or coordination server is necessary for authentication.
Can I use pre-existing keys instead of generating ephemeral ones?
Yes. The Server.Key field accepts a pre-generated key.NodePrivate, and the client similarly accepts existing keys through its configuration. If these fields remain zero-valued, both server and client automatically generate fresh ephemeral keys using key.NewNode() during initialization.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →