xenvsync

Inside xenvsync: AES-256-GCM Encryption and X25519 Team Sharing

Published March 28, 2026 · 12 min read · Technical Deep Dive

Design Goals

xenvsync was built around three non-negotiable security properties:

  1. 1.Authenticated encryption — any tampering with the vault must be detectable before decryption proceeds.
  2. 2.Fresh randomness — encrypting the same plaintext twice must produce different ciphertext, with no nonce reuse.
  3. 3.Local key custody — decryption material must never leave the local machine or pass through a third-party service.

AES-256-GCM satisfies all three. The team sharing layer adds a fourth property: per-member key isolation using X25519 ECDH, so no symmetric secret is ever distributed across a team.

V1 Vault Format: Symmetric Encryption

For solo use (no team roster), xenvsync uses V1: a single AES-256-GCM symmetric key stored at .xenvsync.key with 0600 permissions. The vault file is a base64-encoded blob wrapped in ASCII header and footer markers.

The binary layout inside the base64 encoding is:

V1 binary vault layouttext
[ 12-byte nonce ][ ciphertext (variable length) ][ 16-byte GCM tag ]

Total overhead: 28 bytes + plaintext length
Base64-encoded and wrapped with:
  -----BEGIN XENVSYNC VAULT-----
  <base64 payload>
  -----END XENVSYNC VAULT-----

Nonce: 12 bytes generated from crypto/rand — Go's cryptographically secure random source backed by the operating system. A fresh nonce is generated on every xenvsync push, ensuring ciphertext is non-deterministic even when the plaintext has not changed.

AES-256-GCM: The 256-bit key encrypts the serialized .env content. GCM (Galois/Counter Mode) is an authenticated encryption with associated data (AEAD) scheme — it produces both a ciphertext and a 16-byte authentication tag. The tag covers the full ciphertext and nonce. If any byte of the vault is modified, decryption fails with an authentication error rather than silently returning corrupted plaintext.

Key size: The symmetric key is exactly 32 bytes (256 bits), generated by crypto/rand.Read. Keys shorter than this are rejected by doctor.

V2 Vault Format: Per-Member Key Slots

When a .xenvsync-team.json roster exists, xenvsync upgrades to V2 format. The vault now contains a JSON header with per-member key slots, followed by the same AES-256-GCM encrypted payload.

The core idea: a single symmetric data encryption key (DEK) encrypts the payload. For each team member, an ephemeral X25519 key pair performs an ECDH exchange with the member's long-term public key. The shared secret derived from this exchange encrypts the DEK. Each member gets their own slot containing the ephemeral public key and the encrypted DEK.

V2 vault structuretext
-----BEGIN XENVSYNC V2 VAULT-----
[JSON key slot array]
{"slots": [
  {
    "name": "alice",
    "ephemeralPublicKey": "<base64url>",
    "encryptedKey": "<base64url>"
  },
  {
    "name": "bob",
    "ephemeralPublicKey": "<base64url>",
    "encryptedKey": "<base64url>"
  }
]}
---DATA---
<base64-encoded AES-256-GCM ciphertext (same layout as V1)>
-----END XENVSYNC V2 VAULT-----

X25519 ECDH key exchange: X25519 is the Diffie-Hellman function over Curve25519. It takes a private key and a public key and produces a 32-byte shared secret. For each push operation, xenvsync generates a fresh ephemeral private key, computes ECDH(ephemeral_private, member_public), and uses the result (via HKDF expansion) to encrypt the DEK with AES-256-GCM.

Why ephemeral keys? Using a fresh ephemeral keypair per push means that even if the DEK slot is somehow decrypted, it provides no information about previous vault versions. Each push generates completely independent key material for every slot. This is forward secrecy at the vault level.

Decryption: A member runs xenvsync pull. Their identity's private key is at ~/.xenvsync/identity (0600 permissions). xenvsync scans the slot list for their name, computes ECDH(identity_private, slot.ephemeralPublicKey)to recover the shared secret, decrypts the DEK, then uses the DEK to decrypt the payload. The DEK never leaves the member's machine.

Passphrase-Protected Keys

For additional protection of the key file itself, xenvsync init --passphrase wraps the symmetric key with a key-encryption-key (KEK) derived from a user passphrase.

Passphrase key derivationtext
KEK = scrypt(passphrase, salt, N=32768, r=8, p=1, keyLen=32)
encrypted_key = AES-256-GCM(KEK, key_material)

Stored as: "enc:" + base64(salt || nonce || ciphertext || tag)

Runtime: XENVSYNC_PASSPHRASE env var → derive KEK → decrypt key → proceed

scrypt parameters: N=32768 (2^15) work factor, r=8 block size, p=1 parallelization. These parameters require ~32 MB of memory per derivation attempt, making offline dictionary attacks expensive. The salt is generated fresh per key file.

This provides defense in depth: even if .xenvsync.key is leaked, it cannot be used without the passphrase.

Memory Hygiene

xenvsync zeroes out key material after use via crypto.ZeroBytes, which overwrites the byte slice with zeros before letting the garbage collector reclaim it. This limits the window during which key material exists in process memory and reduces the risk of it persisting in a core dump or memory snapshot.

xenvsync run is the most security-sensitive command: it decrypts the vault in memory, merges the env pairs into the child process environment, spawns the child, and immediately zeroes the key material. The child process inherits only the environment variables — never the key.

Memory zeroing pattern (internal/crypto/zero.go)go
// ZeroBytes overwrites a byte slice with zeros
// to limit key material residency in memory.
func ZeroBytes(b []byte) {
    for i := range b {
        b[i] = 0
    }
}

Key Rotation Security Properties

xenvsync rotate decrypts the current vault, generates fresh key material, and re-encrypts in one atomic step. The ordering matters: the vault is written first, then the key file. If the key write fails, the old key can still decrypt the old vault. This prevents a scenario where the vault has been updated to use a new key that was never saved.

In V2 mode, rotation generates fresh ephemeral X25519 keys for all current roster members. The --revoke <name> flag removes a member from the roster before re-encryption, effectively excluding them from all future decryption. Their existing private key can still decrypt historical vaults committed before the rotation — which is why immediate rotation after offboarding is essential.

Threat Model

Vault committed to public repo

AES-256-GCM ciphertext is computationally infeasible to decrypt without the key. GCM tag ensures tamper detection.

Key file leaked (no passphrase)

Attacker can decrypt all vaults encrypted with that key. Immediate rotation invalidates the key for future vaults.

Key file leaked (passphrase protected)

Attacker must brute-force scrypt(N=32768) — expensive. Rotate key after confirmed exposure.

Team member with access leaks secrets

Per-member key slots limit blast radius to that member's identity. Rotate to exclude compromised identity.

Vault file tampered in transit

GCM authentication tag detects any modification. xenvsync verify catches this explicitly.

Nonce reuse producing repeated ciphertext

12-byte nonce from crypto/rand per operation. Probability of collision across 2^48 operations is negligible.

Further Reading