← Back to Blog

Zero-Dependency Agent Encryption: X25519 + AES-256-GCM in Pure Go

February 12, 2026 cryptography security go

Pilot Protocol encrypts every packet between agents. Tunnel traffic, connection-level data, authentication handshakes — all of it. And it does this with zero external dependencies. No OpenSSL. No libsodium. No CGo. No vendored C libraries. Every cryptographic operation uses Go's standard library: crypto/ecdh, crypto/aes, crypto/cipher, crypto/ed25519, and crypto/sha256.

This is not a philosophical preference. It is a practical requirement. Pilot daemons must compile to a single static binary that runs on any Linux or macOS machine without shared library dependencies. Agents get deployed to edge devices, containers, VMs behind NAT, and machines where installing OpenSSL development headers is not an option. A Go binary with zero CGo dependencies just works everywhere GOOS and GOARCH target.

This article walks through every layer of Pilot's encryption stack, from the initial key exchange to per-packet authenticated encryption, with real Go code for each stage.

Layer 1: Tunnel Encryption with X25519 ECDH

When two Pilot daemons establish a UDP tunnel, they first perform an X25519 Elliptic Curve Diffie-Hellman key exchange. X25519 (Curve25519 in ECDH mode) gives us 128 bits of security with 32-byte keys, is constant-time by design (no timing side channels), and is available in Go's standard library since Go 1.20 via crypto/ecdh.

Key Generation

Each daemon generates an ephemeral X25519 key pair when it starts. This key pair is used for tunnel encryption and is separate from the daemon's long-term Ed25519 identity key (which is used for signing).

// Generate an ephemeral X25519 key pair for tunnel encryption
curve := ecdh.X25519()
privateKey, err := curve.GenerateKey(rand.Reader)
if err != nil {
    return fmt.Errorf("generate X25519 key: %w", err)
}
publicKey := privateKey.PublicKey()

// publicKey.Bytes() is 32 bytes, sent to the peer
// privateKey is never transmitted

Shared Secret Derivation

After exchanging public keys, both sides compute the same shared secret using ECDH:

// Both sides compute identical shared secret
rawSecret, err := privateKey.ECDH(peerPublicKey)
if err != nil {
    return fmt.Errorf("ECDH: %w", err)
}

// Derive AES-256 key using SHA-256 with domain separator
// Domain separator prevents cross-protocol key reuse
h := sha256.New()
h.Write([]byte("pilot-tunnel-v1:"))
h.Write(rawSecret)
tunnelKey := h.Sum(nil) // 32 bytes = AES-256 key

The domain separator "pilot-tunnel-v1:" is critical. Without it, if the same X25519 key pair were accidentally used in a different protocol, the derived keys would be identical, potentially enabling cross-protocol attacks. The domain separator ensures that even with the same ECDH shared secret, the derived key is unique to Pilot's tunnel encryption.

We use SHA-256 as our KDF rather than HKDF because we are deriving a single key from a single high-entropy input (the X25519 shared secret is already uniformly distributed). HKDF's extract-then-expand pattern adds complexity without benefit when the input key material is already a proper secret. SHA-256 of a domain separator concatenated with a uniform random value produces a computationally indistinguishable output.

AES-256-GCM Packet Encryption

With the shared tunnel key established, every UDP packet is encrypted with AES-256-GCM (Galois/Counter Mode). GCM provides both confidentiality (encryption) and integrity (authentication) in a single operation. If a single bit of the ciphertext or the associated data is modified, decryption fails.

// Create AES-256-GCM cipher
block, err := aes.NewCipher(tunnelKey)
if err != nil {
    return fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
    return fmt.Errorf("gcm: %w", err)
}

// Encrypt a packet
// nonce is 12 bytes (see Nonce Management section)
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)

// Wire format: [nonce (12 bytes)][ciphertext + GCM tag (16 bytes)]
packet := append(nonce, ciphertext...)

// Decrypt on the receiving side
nonce := packet[:gcm.NonceSize()]
ciphertext = packet[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
    // Authentication failed: packet was tampered or wrong key
    return fmt.Errorf("decrypt: %w", err)
}

The overhead per packet is 28 bytes: 12 bytes for the nonce and 16 bytes for the GCM authentication tag. On a 1400-byte MTU, that is 2% overhead. On larger payloads, it becomes negligible.

Layer 2: Authenticated Key Exchange (PILA Frame)

ECDH alone is vulnerable to man-in-the-middle attacks. An attacker could intercept the key exchange, perform ECDH with each side independently, and relay decrypted traffic between them. Pilot prevents this with authenticated key exchange using the PILA (Pilot Authentication) frame.

Each daemon has a long-term Ed25519 identity key pair generated on first run and persisted in the identity file. The public key is registered with the rendezvous server. When establishing a tunnel, each side signs its X25519 public key with its Ed25519 identity key:

// PILA frame: authenticate the X25519 key exchange
// Sign: "auth" + our node ID + our X25519 public key
message := []byte("auth")
message = append(message, nodeIDBytes...)     // 4 bytes
message = append(message, x25519PubKey...)    // 32 bytes

signature := ed25519.Sign(identityPrivateKey, message)
// signature is 64 bytes

// Send PILA frame: [x25519_pubkey (32)][signature (64)][node_id (4)]

The receiving side verifies the signature against the sender's Ed25519 public key, which it retrieves from the registry:

// Verify PILA frame from peer
peerEd25519PubKey := registry.GetPublicKey(peerNodeID)

message := []byte("auth")
message = append(message, peerNodeIDBytes...)
message = append(message, peerX25519PubKey...)

if !ed25519.Verify(peerEd25519PubKey, message, peerSignature) {
    return errors.New("PILA auth failed: signature invalid")
}

// Signature valid: this X25519 key genuinely belongs to peerNodeID
// Proceed with ECDH using the verified public key

This design avoids the need for a certificate authority. The trust model is bilateral: each agent decides which peers to trust, and the registry serves as a public key directory (not a certificate issuer). The registry cannot forge signatures because it does not have agents' private keys.

Layer 3: Nonce Management

AES-GCM requires that no nonce is ever reused with the same key. Nonce reuse with GCM is catastrophic: it allows an attacker to recover the authentication key and forge arbitrary ciphertexts. This is not a theoretical concern — it is the most common real-world failure mode of GCM implementations.

Pilot uses a 4-byte random prefix + 8-byte atomic counter nonce scheme:

// Nonce structure: [random_prefix (4 bytes)][counter (8 bytes)]
// Total: 12 bytes (GCM standard nonce size)

type NonceGenerator struct {
    prefix  [4]byte       // random, set once per peer connection
    counter atomic.Uint64 // monotonically increasing
}

func NewNonceGenerator() *NonceGenerator {
    ng := &NonceGenerator{}
    rand.Read(ng.prefix[:]) // crypto/rand, not math/rand
    return ng
}

func (ng *NonceGenerator) Next() [12]byte {
    var nonce [12]byte
    copy(nonce[:4], ng.prefix[:])
    c := ng.counter.Add(1)
    binary.BigEndian.PutUint64(nonce[4:], c)
    return nonce
}

Why the Random Prefix Matters

The counter alone would prevent nonce reuse within a single peer connection. But consider this scenario: daemon A talks to daemon B and daemon C using the same tunnel key (this does not happen in practice, but defense in depth matters). Without the random prefix, both connections start their counters at 1. Nonce reuse.

The 4-byte random prefix gives us 2^32 (4.3 billion) possible starting points. The probability of two connections choosing the same prefix is negligible for any realistic number of concurrent peers. Combined with the 8-byte counter (2^64 packets before wraparound), a daemon can send 18 quintillion packets per peer before needing to re-key. At 1 million packets per second, that is 584,942 years.

Replay Detection: 256-Nonce Sliding Window

Encryption prevents eavesdropping and tampering, but not replay attacks. An attacker who records an encrypted packet can retransmit it later. The receiver will decrypt it successfully (same key, valid nonce, valid tag) and process the duplicate.

Pilot uses a 256-nonce sliding window for replay detection:

// Sliding window replay detector
type ReplayWindow struct {
    mu      sync.Mutex
    highest uint64    // highest nonce counter seen
    bitmap  [4]uint64 // 256-bit bitmap for recent nonces
}

func (rw *ReplayWindow) Check(counter uint64) bool {
    rw.mu.Lock()
    defer rw.mu.Unlock()

    if counter > rw.highest {
        // New highest: shift window forward
        shift := counter - rw.highest
        if shift >= 256 {
            // Jumped far ahead: clear entire bitmap
            rw.bitmap = [4]uint64{}
        } else {
            // Shift bitmap by 'shift' positions
            shiftBitmap(&rw.bitmap, shift)
        }
        rw.highest = counter
        setBit(&rw.bitmap, 0) // mark current as seen
        return true // accept
    }

    diff := rw.highest - counter
    if diff >= 256 {
        return false // too old, reject
    }

    if getBit(&rw.bitmap, diff) {
        return false // already seen, reject (replay)
    }
    setBit(&rw.bitmap, diff)
    return true // accept
}

The 256-nonce window handles out-of-order delivery (common with UDP) while still detecting replays. A packet with a counter value that falls within the window is checked against the bitmap. If the bit is already set, it is a replay and gets dropped. If the counter is older than 256 packets behind the highest seen, it is too old and gets dropped.

Layer 4: Connection-Level Encryption (Port 443)

Tunnel encryption protects packets between daemons. But Pilot also supports connection-level encryption on port 443 for application-layer traffic. This is an additional encryption layer that provides end-to-end security between the application processes, even if the daemon itself were compromised.

Connection-level encryption uses the same X25519 + AES-256-GCM primitives but with a different key negotiation and a different nonce strategy.

Role-Based Nonce Prefix

In a connection, one side is the "server" (listening on port 443) and the other is the "client" (connecting). Both derive the same symmetric key from ECDH, so both could in theory generate the same nonces. To prevent this, Pilot uses role-based nonce prefixes:

// Server always uses 0x01 prefix, client always uses 0x02
// This guarantees nonce uniqueness even with the same key

func newConnNonceGenerator(role string) *NonceGenerator {
    ng := &NonceGenerator{}
    switch role {
    case "server":
        ng.prefix = [4]byte{0x01, 0x00, 0x00, 0x00}
    case "client":
        ng.prefix = [4]byte{0x02, 0x00, 0x00, 0x00}
    }
    return ng
}

This is simpler than the random prefix approach used for tunnel encryption, and it is deterministic: given the same role, you always get the same prefix. The trade-off is that it only works for two-party connections (which is all that connection-level encryption needs). The first byte (0x01 vs 0x02) guarantees that server nonces and client nonces never collide, even if both counters are at the same value.

Why Two Layers of Encryption?

Tunnel encryption operates at the daemon level. If daemon A sends a packet to daemon B, the packet is encrypted with the tunnel key shared between A and B. But the application running on A (say, an HTTP server) might want assurance that even daemon B's own process cannot read the plaintext — only the application process on B that holds the connection key.

In practice, this matters for multi-tenant scenarios where multiple applications share a daemon, or when daemons are operated by different teams within an organization. The tunnel protects against network eavesdroppers; the connection encryption protects against compromised daemons.

What We Chose Not to Do

Every cryptographic design involves trade-offs. Here is what Pilot deliberately omits and why.

No Perfect Forward Secrecy per Message

Protocols like Signal use a ratcheting mechanism (Double Ratchet) to derive a new key for every message. If a key is compromised, only that single message is exposed. This is ideal for messaging apps where conversations last months.

Pilot tunnels are transient. They last for the duration of a session (typically minutes to hours). If the tunnel key is compromised, an attacker can read that session's traffic. But the next session will use new ephemeral X25519 keys and derive a completely new tunnel key. For a transport protocol handling potentially millions of packets per session, the overhead of per-message ratcheting (one ECDH per message) is prohibitive. We trade per-message PFS for per-session PFS, which we consider an acceptable trade-off for a transport layer.

No Post-Quantum Cryptography

X25519 and AES-256 are not quantum-resistant. A sufficiently powerful quantum computer could break X25519 via Shor's algorithm. We are watching the NIST post-quantum standardization process (ML-KEM/Kyber is finalized) and plan to add a hybrid key exchange (X25519 + ML-KEM) when Go's standard library adds support. Until then, X25519 remains the pragmatic choice: it is fast, constant-time, widely analyzed, and available without external dependencies.

No Certificate Chains

Traditional TLS uses certificate chains rooted in a certificate authority (CA). The CA vouches for the server's identity, and the client trusts the CA. Pilot's trust model is bilateral: agent A decides to trust agent B directly, not via a third party. The registry stores public keys (like a DNS for public keys) but does not sign or endorse them. This eliminates the CA as a single point of compromise but requires agents to establish trust through mutual handshakes.

No TLS for Tunnel Traffic

TLS operates over TCP. Pilot tunnels use UDP. We could layer DTLS (Datagram TLS) over our UDP tunnels, but DTLS adds its own framing, retransmission logic, and connection state that would conflict with Pilot's existing transport layer. Instead, we implement the same cryptographic primitives (ECDH + AEAD) directly, giving us the security properties of DTLS without the protocol overhead.

Security Properties Summary

Here is what Pilot's encryption stack guarantees and what it does not:

PropertyStatus
ConfidentialityAES-256-GCM encryption on every packet
IntegrityGCM authentication tag (16 bytes per packet)
AuthenticationEd25519 signatures via PILA frame; prevents MITM
Replay protection256-nonce sliding window per peer
Forward secrecyPer-session (ephemeral X25519), not per-message
Nonce safetyRandom prefix (tunnel) or role prefix (connection)
Post-quantumNot yet; planned hybrid X25519 + ML-KEM
DependenciesZero. Pure Go stdlib.

Performance

Encryption is not free, but it is fast enough to be always-on. On a modern x86_64 CPU with AES-NI instructions (which Go's crypto/aes uses automatically), AES-256-GCM throughput exceeds 5 GB/s. The bottleneck in Pilot is always the network, never the encryption.

Benchmarks on an Intel Xeon (GCP e2-standard-4):

OperationTime
X25519 key generation~45 us
X25519 ECDH~120 us
SHA-256 KDF~0.3 us
AES-256-GCM encrypt (1 KB)~0.5 us
AES-256-GCM encrypt (64 KB)~8 us
Ed25519 sign~50 us
Ed25519 verify~120 us

The key exchange (X25519 + Ed25519 verify + SHA-256) costs about 300 microseconds total. This happens once per tunnel setup. After that, per-packet encryption adds less than 1 microsecond per kilobyte. At 10,000 packets per second (a heavily loaded tunnel), encryption consumes about 5 milliseconds of CPU time — 0.5% of a single core.

For a deeper look at how encryption affects end-to-end latency in practice, see our benchmarking article.

Verifying the Implementation

Cryptographic code is uniquely dangerous because it can be subtly wrong while appearing to work perfectly. A broken nonce generator will encrypt and decrypt correctly — it just will not be secure. We verify our implementation through:

All of these are part of the 226-test suite that runs on every commit.

A note on "zero dependency": Go's crypto packages are, of course, dependencies in the sense that they are code we did not write. What we mean is zero external dependencies: nothing outside the Go standard library. No go.sum entries for cryptographic libraries. No CGo. No linking against system libraries. The attack surface is exactly Go's stdlib crypto, which is maintained by the Go security team and receives constant scrutiny.

Read the Source

The entire encryption implementation is in a single package. Readable, auditable, zero dependencies.

View on GitHub