What Are Tailcat Pre-Shared Keys? A Deep Dive into PSK Security in tailscale/tailcat
Tailcat pre-shared keys are optional 256-bit WireGuard secrets that add post-quantum confidentiality to tunnels, making them unreadable even by compromised DERP relays.
Tailcat, the peer-to-peer file transfer tool from Tailscale, implements an advanced security layer through pre-shared keys (PSKs). These cryptographic secrets are mixed into every WireGuard handshake, ensuring that only the two communicating endpoints can decrypt traffic. This article examines how PSKs work in the tailscale/tailcat codebase, including generation, encoding, and practical configuration.
The Role of Pre-Shared Keys in Tailcat
A tailcat pre-shared key serves three critical functions in the tunnel establishment process:
- Endpoint-only secrecy: The PSK is never transmitted over the wire; both peers must possess it independently.
- Post-quantum protection: Even if a DERP relay observes public keys, it cannot join or decrypt the tunnel without the 256-bit PSK.
- Address secrecy: When a non-zero PSK is present, the entire tailcat address becomes a secret because it embeds the key.
According to the source code in tailcat.go, the PSK is implemented as a 32-byte value using the constant device.NoisePresharedKeySize from the WireGuard device package.
How Tailcat Generates Pre-Shared Keys
The NewPresharedKey() function in tailcat.go (L210-L218) creates cryptographically secure PSKs:
// tailcat.go L210-L218
func NewPresharedKey() PresharedKey {
var psk [32]byte
for {
if _, err := rand.Read(psk[:]); err != nil {
panic(err)
}
// Retry until non-zero (zero value disables PSK layer)
if psk != [32]byte{} {
break
}
}
return PresharedKey(psk)
}
The retry loop ensures no accidentally zero-valued keys, which would silently disable the security layer.
PSK Encoding and Wire Format
Tailcat uses CBOR/JSON for PSK serialization. The PresharedKey type implements custom marshalling methods in tailcat.go (L247-L255):
// Encode to text: "psk:<hex-encoded-32-bytes>"
func (p PresharedKey) MarshalText() ([]byte, error) {
return []byte("psk:" + hex.EncodeToString(p[:])), nil
}
// Decode from text format
func (p *PresharedKey) UnmarshalText(text []byte) error {
const prefix = "psk:"
if !bytes.HasPrefix(text, []byte(prefix)) {
return fmt.Errorf("invalid PSK format")
}
// ... hex decoding logic
}
The "psk:" prefix makes PSKs self-identifying in serialized addresses and wire messages. The wire.go file defines how PSKs travel between peers via the ConnInfo structure's PresharedKey *PresharedKey field.
Zero-Value Semantics: Disabling PSKs
A zero-valued PresharedKey{} disables the pre-shared-key layer entirely. As noted in tailcat.go (L163-L169), this exists only for backward compatibility:
// tailcat.go L163-L169
// A zero PresharedKey disables the extra secrecy layer.
// This produces shorter addresses but is NOT recommended.
// Kept only for compatibility with tailcat v0.5.0 and earlier.
When disabled, addresses become shorter and shareable more easily, but security degrades significantly—a malicious DERP operator could potentially intercept traffic. The source explicitly warns against this in tailcat.go (L414-L417).
Peer Configuration and WireGuard Integration
When the server initializes peer connections, non-zero PSKs are injected into WireGuard's peer configuration. From tailcat.go (L1349):
// tailcat.go L1349
cfg := &wgtypes.PeerConfig{
PublicKey: peerKey,
PresharedKey: device.NoisePresharedKey(b.presharedKey),
// ... endpoint, allowed IPs
}
This binds the 256-bit tailcat PSK directly into the Noise protocol handshake, mixing it with the ephemeral key exchange.
CLI Control: Enabling and Disabling PSKs
The cmd/tailcat/tailcat.go file (L105) exposes PSK control via the --psk flag:
# Default: include PSK in address
tailcat serve --psk
# Compatibility mode: shorter address, no PSK
tailcat serve --psk=false
Programmatic usage in Go:
// Server with automatic PSK generation
srv := &tailcat.Server{
Key: myNodeKey,
PresharedKey: tailcat.NewPresharedKey(), // recommended
}
// Server without PSK (discouraged)
srv := &tailcat.Server{
Key: myNodeKey,
DisablePresharedKey: true, // zero value used
}
Verifying PSK Presence in Code
The IsZero() method checks whether a PSK is configured:
psk := tailcat.NewPresharedKey()
if psk.IsZero() {
log.Println("WARNING: PSK disabled—tunnel vulnerable to DERP compromise")
} else {
encoded, _ := psk.MarshalText()
log.Printf("PSK active: %s...", string(encoded)[:16])
}
Summary
- Tailcat pre-shared keys are 256-bit WireGuard secrets that add post-quantum confidentiality to peer-to-peer tunnels.
- Generation:
NewPresharedKey()intailcat.gocreates secure random values, rejecting all-zero results. - Encoding: PSKs serialize as
"psk:<hex>"viaMarshalText/UnmarshalTextfor CBOR/JSON wire transfer. - Zero value: Disables the security layer; retained only for v0.5.0 client compatibility.
- CLI: Controlled via
--pskflag incmd/tailcat/tailcat.go. - Security recommendation: Always enable PSKs; disabling exposes tunnels to malicious relay operators.
Frequently Asked Questions
What happens if a tailcat PSK is compromised?
If a pre-shared key leaks, an attacker with the key and access to the DERP relay could theoretically decrypt traffic. However, the attacker would still need to intercept the handshake timing. Rotate keys by generating a new server with NewPresharedKey() and redistributing the new address.
Why would anyone disable PSKs with --psk=false?
Disabling PSKs produces shorter, more shareable addresses. The tailscale/tailcat source explicitly warns this is only for compatibility with tailcat v0.5.0 and earlier. Modern clients should always use PSKs for post-quantum protection against relay compromise.
How do I extract the PSK from a running tailcat server?
The PSK is embedded in the serialized server address. Parse the address string and locate the "psk:" component, then use PresharedKey.UnmarshalText(). Direct field access requires the *tailcat.Server pointer if you control the server instance.
Does tailcat PSK affect performance?
No measurable impact. The 256-bit PSK is mixed cryptographically into the WireGuard handshake, not used for payload encryption. The handshake occurs once per session; subsequent data transfer uses standard WireGuard ChaCha20-Poly1305 at full speed.
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 →