Files
lneto/tcp/syncookie.go
T
2026-04-16 17:21:23 -03:00

245 lines
7.7 KiB
Go

package tcp
import (
"encoding/binary"
"io"
"github.com/soypat/lneto"
)
// Embed low 5 bits of counter into cookie for efficient validation.
// Lower bits of cookie are counter bits.
const (
cookiebits = 32
counterbits = 5
hashbits = cookiebits - counterbits
countermsk = (1 << counterbits) - 1
)
// SYNCookieJar implements SYN cookie generation and validation for TCP SYN flood protection.
// SYN cookies allow a server to avoid allocating state for half-open connections by
// encoding connection parameters into the Initial Sequence Number (ISS) of the SYN-ACK response.
//
// The cookie encodes:
// - A hash of the connection tuple (src IP, dst IP, src port, dst port)
// - A timestamp counter for cookie expiration
// - MSS index (optional, for preserving Maximum Segment Size negotiation)
//
// See RFC 4987 for background on SYN flood attacks and cookie-based mitigations.
type SYNCookieJar struct {
// counter is incremented periodically or under pressure to expire old cookies.
// Cookies generated with a counter more than maxCounterDelta behind current are rejected.
counter uint32
// maxCounterDelta defines how many counter increments a cookie remains valid.
// A value of 2 means cookies from counter, counter-1, and counter-2 are accepted.
maxCounterDelta uint32
// secret is the key used for cookie generation. Should be random and kept private.
secret [16]byte
}
// SYNCookieConfig contains configuration for SYN cookie initialization.
type SYNCookieConfig struct {
// Rand is used for entropy generation of cookies.
Rand io.Reader
// MaxCounterDelta defines cookie validity window in counter increments.
// Recommended value is 1-2. Zero defaults to 1.
MaxCounterDelta uint32
}
var errInvalidCookie error = lneto.ErrMismatch
// Reset initializes or reinitializes the SYNCookie with the given configuration.
// The counter is preserved across resets to maintain cookie validity during secret rotation.
func (sc *SYNCookieJar) Reset(config SYNCookieConfig) error {
if config.Rand == nil {
return lneto.ErrInvalidConfig
}
_, err := io.ReadFull(config.Rand, sc.secret[:])
if err != nil {
return err
}
maxDelta := config.MaxCounterDelta
if maxDelta == 0 {
maxDelta = 1
}
sc.maxCounterDelta = maxDelta
// counter is intentionally NOT reset to preserve validity of recent cookies
return nil
}
// IncrementCounter advances the counter, which will eventually expire old cookies.
// Call this periodically (e.g., every few seconds) or when under SYN flood pressure.
func (sc *SYNCookieJar) IncrementCounter() {
sc.counter++
}
// Counter returns the current counter value.
func (sc *SYNCookieJar) Counter() uint32 {
return sc.counter
}
// MakeSYNCookie creates a SYN cookie value to be used as the ISS in a SYN-ACK response.
// The cookie encodes the connection tuple and current counter for later validation.
//
// Parameters:
// - srcAddr: source IP address (4 bytes for IPv4, 16 for IPv6)
// - dstAddr: destination IP address
// - srcPort: source TCP port
// - dstPort: destination TCP port
// - clientISN: the client's Initial Sequence Number from the SYN packet
func (sc *SYNCookieJar) MakeSYNCookie(srcAddr, dstAddr []byte, srcPort, dstPort uint16, clientISN Value) Value {
return sc.generateWithCounter(srcAddr, dstAddr, srcPort, dstPort, clientISN, sc.counter)
}
// generateWithCounter creates a cookie using a specific counter value.
func (sc *SYNCookieJar) generateWithCounter(srcAddr, dstAddr []byte, srcPort, dstPort uint16, clientISN Value, counter uint32) Value {
// Cookie structure (32 bits):
// [5 bits: counter low bits][27 bits: hash of tuple+secret+counter]
//
// The counter bits allow validation to check multiple counter values efficiently.
// The hash provides cryptographic binding to the connection tuple.
hash := sc.hashTuple(srcAddr, dstAddr, srcPort, dstPort, clientISN, counter)
hash = hash << counterbits
return Value(hash | counter&countermsk)
}
// ValidateSYNCookie checks if an ACK number from a client completing the handshake contains
// a valid cookie. Returns the original cookie value if valid.
//
// Parameters:
// - srcAddr, dstAddr: IP addresses (must match original SYN)
// - srcPort, dstPort: TCP ports (must match original SYN)
// - clientISN: client's ISN from original SYN (can be derived from ack-1 of final ACK)
// - ackNum: the ACK number from the client's ACK packet (should be cookie+1)
//
// Returns the cookie value and nil error if valid, or zero and error if invalid.
func (sc *SYNCookieJar) ValidateSYNCookie(srcAddr, dstAddr []byte, srcPort, dstPort uint16, clientISN Value, ackNum Value) (Value, error) {
// Client ACKs cookie+1, so the cookie is ackNum-1
cookie := ackNum - 1
// Extract counter bits from cookie
cookieCounterBits := uint32(cookie) & countermsk
// Try validation with current counter and allowed previous values
for delta := uint32(0); delta <= sc.maxCounterDelta; delta++ {
tryCounter := sc.counter - delta
tryCounterBits := tryCounter & countermsk
if tryCounterBits != cookieCounterBits {
continue
}
// Counter bits match, verify full hash
expected := sc.generateWithCounter(srcAddr, dstAddr, srcPort, dstPort, clientISN, tryCounter)
if expected == cookie {
return cookie, nil
}
}
return 0, errInvalidCookie
}
// hashTuple computes a hash of the connection tuple mixed with secret and counter.
// Uses a simple but effective mixing function suitable for embedded systems.
func (sc *SYNCookieJar) hashTuple(srcAddr, dstAddr []byte, srcPort, dstPort uint16, clientISN Value, counter uint32) uint32 {
// Initialize with secret words
h0 := binary.LittleEndian.Uint32(sc.secret[0:4])
h1 := binary.LittleEndian.Uint32(sc.secret[4:8])
h2 := binary.LittleEndian.Uint32(sc.secret[8:12])
h3 := binary.LittleEndian.Uint32(sc.secret[12:16])
// Mix in connection tuple
h0 ^= uint32(srcPort) | (uint32(dstPort) << 16)
h1 ^= uint32(clientISN)
h2 ^= counter
// Mix in addresses (handle both IPv4 and IPv6)
for i := 0; i+3 < len(srcAddr); i += 4 {
h3 ^= binary.LittleEndian.Uint32(srcAddr[i:])
h0, h1, h2, h3 = mixRound(h0, h1, h2, h3)
}
// Handle remaining bytes of srcAddr
if rem := len(srcAddr) % 4; rem != 0 {
var last uint32
for i := range rem {
last |= uint32(srcAddr[len(srcAddr)-rem+i]) << (i * 8)
}
h3 ^= last
}
for i := 0; i+3 < len(dstAddr); i += 4 {
h0 ^= binary.LittleEndian.Uint32(dstAddr[i:])
h0, h1, h2, h3 = mixRound(h0, h1, h2, h3)
}
// Handle remaining bytes of dstAddr
if rem := len(dstAddr) % 4; rem != 0 {
var last uint32
for i := range rem {
last |= uint32(dstAddr[len(dstAddr)-rem+i]) << (i * 8)
}
h0 ^= last
}
// Final mixing rounds
h0, h1, h2, h3 = mixRound(h0, h1, h2, h3)
h0, h1, h2, h3 = mixRound(h0, h1, h2, h3)
return h0 ^ h1 ^ h2 ^ h3
}
// mixRound performs one round of mixing, similar to SipHash quarter-round.
func mixRound(a, b, c, d uint32) (uint32, uint32, uint32, uint32) {
a += b
d ^= a
d = rotl32(d, 16)
c += d
b ^= c
b = rotl32(b, 12)
a += b
d ^= a
d = rotl32(d, 8)
c += d
b ^= c
b = rotl32(b, 7)
return a, b, c, d
}
// rotl32 performs a 32-bit left rotation.
func rotl32(x uint32, n int) uint32 {
return (x << n) | (x >> (32 - n))
}
// encodeMSSIndex encodes an MSS value into a 2-bit index for embedding in cookies.
// Common MSS values are mapped to indices 0-3. Returns the closest match.
func encodeMSSIndex(mss uint16) uint8 {
// Common MSS values: 536 (minimum), 1460 (Ethernet), 1440 (PPPoE), 8960 (jumbo)
switch {
case mss <= 536:
return 0
case mss <= 1220:
return 1
case mss <= 1460:
return 2
default:
return 3
}
}
// decodeMSSIndex converts a 2-bit index back to an MSS value.
func decodeMSSIndex(idx uint8) uint16 {
switch idx & 0x3 {
case 0:
return 536
case 1:
return 1220
case 2:
return 1460
default:
return 8960
}
}