mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 16:06:52 +01:00
Charge staged reservations to each tunnel instead of dividing a static share of the window by routines. Several readers sending on one tunnel then share its credit: the limit is half the peer replay window, assumed from ReplayWindow, which must be a power of two of at least 8192 (checked at compile time). The other half allows finite unreserved traffic (inline and control sends, relay forwarding through SendVia) and network reordering; unlimited bypass traffic or delay can still exceed it. An outstanding-count cap alone would let a fast reader repeatedly send and reacquire credit, overtaking a paused reader without bound. So once readers overlap, spent credit is only recycled when every reservation has drained. A sole ordered reader gets each completion refunded, since its later batches cannot overtake its earlier ones. A reader that fails to acquire ends sole ownership, so a busy owner cannot keep a larger waiter blocked forever. This restores progress, not FIFO fairness. All transitions are a single CAS. Waiting readers flush their own oldest batch before retrying and never hold a partial acquisition: a failed multi-tunnel (relay) acquisition rolls back. Credit is released only after the flush or an explicit drop. A read that alone exceeds a tunnel's credit, such as a tiny-MSS superpacket, is sent segment by segment, synchronously. Acquisition is per read, so pressure on one tunnel also delays the other tunnels in the same read. The combined staged transmit, receive and shared credit measured 15 to 25 Gbit/s over one tunnel with eight TCP streams between two 100 GbE hosts. Tests model overtaking, ordered reuse, a blocked reader behind a busy owner, and relay rollback with both window sizes, and run eight competing readers plus unreserved control traffic on one tunnel, checking every packet is accepted by the receiver's replay window in wire order.
158 lines
5.5 KiB
Go
158 lines
5.5 KiB
Go
package nebula
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/slackhq/nebula/cert"
|
|
"github.com/slackhq/nebula/handshake"
|
|
"github.com/slackhq/nebula/header"
|
|
"github.com/slackhq/nebula/noiseutil"
|
|
)
|
|
|
|
const (
|
|
// ReplayWindow is the build-time receive window and the assumed peer window
|
|
// for TX reservation credit. Use a power of two; peers must match or have a
|
|
// larger receive window. The bitmap costs ReplayWindow/8 bytes per tunnel.
|
|
ReplayWindow = 8192
|
|
|
|
// RehandshakeAfterMessages rolls keys inside the AES-GCM data-volume margin (~2^-36 advantage at 64KB frames).
|
|
RehandshakeAfterMessages = uint64(1) << 34
|
|
|
|
// RejectAfterMessages is the nonce ceiling enforced by noiseutil; a tunnel here is deleted locally, not notified.
|
|
RejectAfterMessages = noiseutil.RejectAfterMessages
|
|
)
|
|
|
|
// RehandshakeAfterMessages must stay below RejectAfterMessages so tunnels roll before the hard send stop.
|
|
const _ = RejectAfterMessages - RehandshakeAfterMessages
|
|
|
|
// sessionEpoch hands out a receiver-local ordinal to every ConnectionState at creation. The RX
|
|
// staging sort (overlay/batch) orders packets by (epoch, message counter). A re-handshake never
|
|
// rekeys an existing tunnel; it brings up a new hostinfo and ConnectionState with a counter space
|
|
// starting near zero, while the old tunnel keeps decrypting until torn down. During that cutover
|
|
// one flush batch can hold packets from both tunnels, and the epoch keeps the old tunnel's
|
|
// packets sorted first.
|
|
var sessionEpoch atomic.Uint64
|
|
|
|
type ConnectionState struct {
|
|
eKey noiseutil.CipherState
|
|
dKey noiseutil.CipherState
|
|
myCert cert.Certificate
|
|
peerCert *cert.CachedCertificate
|
|
initiator bool
|
|
messageCounter atomic.Uint64
|
|
txBudget txBudget
|
|
window *Bits
|
|
decryptLock sync.Mutex
|
|
writeLock sync.Mutex
|
|
// epoch is this session's sessionEpoch ordinal. Immutable after creation.
|
|
epoch uint64
|
|
}
|
|
|
|
// newConnectionStateFromResult builds a fully-populated ConnectionState from a
|
|
// completed handshake.Result. It seeds messageCounter and the replay window so
|
|
// that the post-handshake message indices already used on the wire don't count
|
|
// as missed traffic in the data plane.
|
|
func newConnectionStateFromResult(r *handshake.Result) (*ConnectionState, error) {
|
|
// Refuse a MessageIndex too big for the replay window: it can only be a bug, and would spin the seed loop below.
|
|
if r.MessageIndex >= ReplayWindow {
|
|
return nil, fmt.Errorf("handshake message index %d exceeds replay window", r.MessageIndex)
|
|
}
|
|
|
|
ci := &ConnectionState{
|
|
myCert: r.MyCert,
|
|
initiator: r.Initiator,
|
|
peerCert: r.RemoteCert,
|
|
eKey: noiseutil.NewCipherState(r.EKey, r.Cipher),
|
|
dKey: noiseutil.NewCipherState(r.DKey, r.Cipher),
|
|
window: NewBits(ReplayWindow),
|
|
epoch: sessionEpoch.Add(1),
|
|
}
|
|
ci.messageCounter.Add(r.MessageIndex)
|
|
for i := uint64(1); i <= r.MessageIndex; i++ {
|
|
ci.window.Update(nil, i)
|
|
}
|
|
return ci, nil
|
|
}
|
|
|
|
func (cs *ConnectionState) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(m{
|
|
"certificate": cs.peerCert,
|
|
"initiator": cs.initiator,
|
|
"message_counter": cs.messageCounter.Load(),
|
|
})
|
|
}
|
|
|
|
// NextMessageCounter reserves the next 1-based counter; RejectAfterMessages is the first we refuse, pinned to not wrap.
|
|
func (cs *ConnectionState) NextMessageCounter() (uint64, bool) {
|
|
c := cs.messageCounter.Add(1)
|
|
if c >= RejectAfterMessages {
|
|
cs.messageCounter.Store(RejectAfterMessages)
|
|
return c, false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
func (cs *ConnectionState) Curve() cert.Curve {
|
|
return cs.myCert.Curve()
|
|
}
|
|
|
|
func (cs *ConnectionState) Decrypt(l *slog.Logger, messageCounter uint64, packet []byte, nb []byte) ([]byte, error) {
|
|
cs.decryptLock.Lock()
|
|
result := cs.window.Check(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return nil, ErrAlreadySeen
|
|
}
|
|
|
|
out, err := cs.decryptPacket(messageCounter, packet, nb)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cs.decryptLock.Lock()
|
|
result = cs.window.Update(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return nil, ErrAlreadySeen
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decryptPacket authenticates and decrypts packet in place, leaving the replay window to the caller.
|
|
func (cs *ConnectionState) decryptPacket(messageCounter uint64, packet, nb []byte) ([]byte, error) {
|
|
return cs.dKey.DecryptDanger(packet[header.Len:header.Len], packet[:header.Len], packet[header.Len:], messageCounter, nb)
|
|
}
|
|
|
|
func (cs *ConnectionState) VerifyRelay(l *slog.Logger, messageCounter uint64, packet []byte, nb []byte) error {
|
|
cs.decryptLock.Lock()
|
|
result := cs.window.Check(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return ErrAlreadySeen
|
|
}
|
|
|
|
// The entire body is sent as AD, not encrypted.
|
|
// The packet consists of a 16-byte parsed Nebula header, Associated Data-protected payload, and a trailing 16-byte AEAD signature value.
|
|
// The packet is guaranteed to be at least 16 bytes at this point, b/c it got past the h.Parse() call above. If it's
|
|
// otherwise malformed (meaning, there is no trailing 16 byte AEAD value), then this will result in at worst a 0-length slice
|
|
// which will gracefully fail in the DecryptDanger call.
|
|
signedPayload := packet[:len(packet)-cs.dKey.Overhead()]
|
|
signatureValue := packet[len(packet)-cs.dKey.Overhead():]
|
|
_, err := cs.dKey.DecryptDanger(nil, signedPayload, signatureValue, messageCounter, nb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cs.decryptLock.Lock()
|
|
result = cs.window.Update(l, messageCounter)
|
|
cs.decryptLock.Unlock()
|
|
if !result {
|
|
return ErrAlreadySeen
|
|
}
|
|
return nil
|
|
}
|