Why Rekey
A single session key for the entire connection is a liability. If it leaks through malware, a memory dump, or a side-channel attack, everything transferred during the session is exposed. Forward secrecy limits the damage by rotating keys periodically, so that compromise of one key only exposes a fraction of the session.
KEIBIDROP is a file transfer tool; users routinely send contracts, medical records, and source code. A session might last hours and transfer gigabytes, and using one key for all of that is an unnecessary exposure.
Rekey Thresholds
Two triggers, whichever comes first:
// pkg/session/secureconn.go
const (
RekeyBytesThreshold = 1 << 30 // 1 GB
RekeyMsgsThreshold = 1 << 20 // ~1M messages
)
The 1 GB threshold covers large file transfers. Transfer a 5 GB video and you get 5 key rotations during that single transfer. The 1M message threshold covers chatty control channels: heartbeats, sync notifications, file metadata updates. Both counters are tracked atomically.
The Rekey Protocol
Rekeying uses the same hybrid approach as the initial handshake. The initiator generates fresh seeds, encapsulates them with both X25519 and ML-KEM, and sends them to the responder:
// pkg/session/rekey.go
func CreateRekeyRequest(ownKeys *kbc.OwnKeys,
peerKeys *kbc.PeerKeys, epoch uint64) (*bindings.RekeyRequest, []byte, error) {
// Generate new random seed and encapsulate with X25519
seed1 := kbc.GenerateSeed()
encSeed1, err := kbc.X25519Encapsulate(seed1,
ownKeys.X25519Private, peerKeys.X25519Public)
if err != nil {
return nil, nil, fmt.Errorf("x25519 encapsulate failed: %w", err)
}
// Encapsulate with ML-KEM
seed2, encSeed2 := peerKeys.MlKemPublic.Encapsulate()
// Derive the new key from both seeds
newKey, err := kbc.DeriveChaCha20Key(seed1, seed2)
if err != nil {
return nil, nil, fmt.Errorf("key derivation failed: %w", err)
}
req := &bindings.RekeyRequest{
EncSeeds: map[string][]byte{
"x25519": encSeed1,
"mlkem": encSeed2,
},
Epoch: epoch,
}
return req, newKey, nil
}
The responder decapsulates both seeds, derives the same key, and sends back its own RekeyResponse with new seeds for the reverse direction. Each direction gets an independent key.
Counter-Based Nonces
ChaCha20-Poly1305 requires a unique 12-byte nonce for every message encrypted with the
same key. Random nonces cost ~500ns each (reading from /dev/urandom).
Counter-based nonces cost ~1ns (atomic increment). For a file transfer tool pushing
millions of encrypted messages per session, this matters.
// pkg/crypto/symmetric.go
type NonceGenerator struct {
prefix [4]byte // Direction/session identifier
counter atomic.Uint64 // Monotonic counter
}
func (ng *NonceGenerator) Next() [NonceSize]byte {
var nonce [NonceSize]byte
copy(nonce[:4], ng.prefix[:])
binary.BigEndian.PutUint64(nonce[4:], ng.counter.Add(1))
return nonce
}
The nonce structure is [4-byte prefix | 8-byte counter] = 12 bytes total.
The counter is monotonic and thread-safe via atomic.Uint64.
Direction Prefixes
Nonce reuse is catastrophic for ChaCha20-Poly1305. Two messages encrypted with the same key and nonce allow an attacker to XOR the ciphertexts and recover plaintext. With two peers using the same session key, both could independently reach counter value 1, producing the same nonce.
The fix is different prefixes for each direction.
// pkg/session/secureconn.go
const (
NoncePrefixOutbound uint32 = 0x4F555442 // "OUTB"
NoncePrefixInbound uint32 = 0x494E4244 // "INBD"
)
Even if both peers are at counter=1, their nonces are different:
OUTB00000001 vs INBD00000001. The prefix ensures uniqueness
across directions without coordination.
Message Framing
Each encrypted message is framed with a 4-byte length header:
[4-byte length | nonce(12) | ciphertext | auth_tag(16)]
The SecureWriter handles framing automatically. It takes a plaintext message, encrypts it with the next counter-based nonce, prepends the length, and writes the result:
// pkg/session/secureconn.go
type SecureWriter struct {
w io.Writer
kek []byte
nonce *kbc.NonceGenerator
}
func NewSecureWriter(w io.Writer, kek []byte) *SecureWriter {
return &SecureWriter{
w: w,
kek: kek,
nonce: kbc.NewNonceGenerator(NoncePrefixOutbound),
}
}
Key Derivation
Both seeds (from X25519 and ML-KEM) are concatenated and passed to HKDF-SHA512 with a domain-specific label:
// pkg/crypto/asymmetric.go
func DeriveChaCha20Key(sharedSecrets ...[]byte) ([]byte, error) {
return deriveKeyInternal(sha512.New,
"KeibiDrop-ChaCha20-Poly1305-SEK", KeySize, sharedSecrets...)
}
The label "KeibiDrop-ChaCha20-Poly1305-SEK" provides domain separation.
Even if the same seed material were used in a different protocol, the derived keys would
be different.
Epoch Tracking
Each rekey increments an epoch counter. The RekeyRequest includes the expected epoch. The responder rejects requests with an unexpected epoch, preventing replay attacks where an attacker resends an old RekeyRequest to force a key rollback.
The epoch also helps with debugging. If a rekey fails, the log shows which epoch was expected and which was received.
Summary
Automatic rekeying every 1 GB ensures that at most 1 GB of data is exposed per key compromise. The hybrid ML-KEM + X25519 rekey protocol maintains quantum resistance through the entire session, not just the initial handshake. Counter-based nonces with direction prefixes handle millions of encrypted messages without performance overhead and without risk of nonce reuse.