diff --git a/tcp/syncookie.go b/tcp/syncookie.go new file mode 100644 index 0000000..b5989ce --- /dev/null +++ b/tcp/syncookie.go @@ -0,0 +1,245 @@ +package tcp + +import ( + "encoding/binary" + "errors" + "io" +) + +// 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 = errors.New("tcp: invalid SYN cookie") +) + +// 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 errors.New("need rand function") + } + _, 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 := 0; i < rem; i++ { + 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 := 0; i < rem; i++ { + 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 + } +} diff --git a/tcp/syncookie_test.go b/tcp/syncookie_test.go new file mode 100644 index 0000000..e81fdac --- /dev/null +++ b/tcp/syncookie_test.go @@ -0,0 +1,253 @@ +package tcp + +import ( + "math/rand" + "testing" +) + +func TestSYNCookie_ResetValidation(t *testing.T) { + var sc SYNCookieJar + + // Zero secret should fail + err := sc.Reset(SYNCookieConfig{}) + if err == nil { + t.Errorf("expected error, got %v", err) + } + rng := rand.New(rand.NewSource(1)) + // Valid secret should succeed + err = sc.Reset(SYNCookieConfig{Rand: rng}) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } +} + +func TestSYNCookie_GenerateValidate(t *testing.T) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + err := sc.Reset(SYNCookieConfig{Rand: rng, MaxCounterDelta: 2}) + if err != nil { + t.Fatal(err) + } + + srcAddr := []byte{192, 168, 1, 100} + dstAddr := []byte{10, 0, 0, 1} + srcPort := uint16(54321) + dstPort := uint16(80) + clientISN := Value(0x12345678) + + // Generate cookie + cookie := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + + // Validate with ACK = cookie + 1 + ackNum := cookie + 1 + validatedCookie, err := sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err != nil { + t.Errorf("expected valid cookie, got error: %v", err) + } + if validatedCookie != cookie { + t.Errorf("expected cookie %d, got %d", cookie, validatedCookie) + } +} + +func TestSYNCookie_CounterExpiration(t *testing.T) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + err := sc.Reset(SYNCookieConfig{Rand: rng, MaxCounterDelta: 1}) + if err != nil { + t.Fatal(err) + } + + srcAddr := []byte{192, 168, 1, 100} + dstAddr := []byte{10, 0, 0, 1} + srcPort := uint16(54321) + dstPort := uint16(80) + clientISN := Value(0x12345678) + + // Generate cookie at counter=0 + cookie := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + ackNum := cookie + 1 + + // Should validate at counter=0 + _, err = sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err != nil { + t.Errorf("expected valid at counter=0, got: %v", err) + } + + // Increment counter once - should still validate (within delta=1) + sc.IncrementCounter() + _, err = sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err != nil { + t.Errorf("expected valid at counter=1, got: %v", err) + } + + // Increment again - should still validate (counter=2, cookie from 0, delta=1 allows 1 and 2) + sc.IncrementCounter() + _, err = sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err == nil { + t.Errorf("expected cookie to be expired at counter=2 with delta=1") + } +} + +func TestSYNCookie_DifferentTuples(t *testing.T) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + err := sc.Reset(SYNCookieConfig{Rand: rng}) + if err != nil { + t.Fatal(err) + } + + srcAddr := []byte{192, 168, 1, 100} + dstAddr := []byte{10, 0, 0, 1} + srcPort := uint16(54321) + dstPort := uint16(80) + clientISN := Value(0x12345678) + + cookie := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + ackNum := cookie + 1 + + // Different source address should fail + wrongSrcAddr := []byte{192, 168, 1, 101} + _, err = sc.ValidateSYNCookie(wrongSrcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err == nil { + t.Error("expected error for wrong source address") + } + + // Different port should fail + _, err = sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort+1, dstPort, clientISN, ackNum) + if err == nil { + t.Error("expected error for wrong source port") + } + + // Different clientISN should fail + _, err = sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN+1, ackNum) + if err == nil { + t.Error("expected error for wrong client ISN") + } + + // Correct tuple should succeed + _, err = sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err != nil { + t.Errorf("expected success for correct tuple, got: %v", err) + } +} + +func TestSYNCookie_IPv6(t *testing.T) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + + err := sc.Reset(SYNCookieConfig{Rand: rng}) + if err != nil { + t.Fatal(err) + } + + // IPv6 addresses + srcAddr := []byte{0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01} + dstAddr := []byte{0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02} + srcPort := uint16(54321) + dstPort := uint16(443) + clientISN := Value(0xDEADBEEF) + + cookie := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + ackNum := cookie + 1 + + validatedCookie, err := sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + if err != nil { + t.Errorf("expected valid IPv6 cookie, got error: %v", err) + } + if validatedCookie != cookie { + t.Errorf("expected cookie %d, got %d", cookie, validatedCookie) + } +} + +func TestSYNCookie_Deterministic(t *testing.T) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + err := sc.Reset(SYNCookieConfig{Rand: rng}) + if err != nil { + t.Fatal(err) + } + + srcAddr := []byte{192, 168, 1, 100} + dstAddr := []byte{10, 0, 0, 1} + srcPort := uint16(54321) + dstPort := uint16(80) + clientISN := Value(0x12345678) + + // Same inputs should produce same cookie + cookie1 := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + cookie2 := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + + if cookie1 != cookie2 { + t.Errorf("expected deterministic cookies: %d != %d", cookie1, cookie2) + } +} + +func TestMSSIndexEncoding(t *testing.T) { + tests := []struct { + mss uint16 + expectedIdx uint8 + }{ + {200, 0}, + {536, 0}, + {537, 1}, + {1220, 1}, + {1221, 2}, + {1460, 2}, + {1461, 3}, + {8960, 3}, + {9000, 3}, + } + + for _, tc := range tests { + idx := encodeMSSIndex(tc.mss) + if idx != tc.expectedIdx { + t.Errorf("EncodeMSSIndex(%d) = %d, want %d", tc.mss, idx, tc.expectedIdx) + } + } + + // Test round-trip for decoded values + for idx := uint8(0); idx <= 3; idx++ { + mss := decodeMSSIndex(idx) + reIdx := encodeMSSIndex(mss) + if reIdx != idx { + t.Errorf("MSS index round-trip failed: %d -> %d -> %d", idx, mss, reIdx) + } + } +} + +func BenchmarkSYNCookie_Generate(b *testing.B) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + sc.Reset(SYNCookieConfig{Rand: rng}) + + srcAddr := []byte{192, 168, 1, 100} + dstAddr := []byte{10, 0, 0, 1} + srcPort := uint16(54321) + dstPort := uint16(80) + clientISN := Value(0x12345678) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + } +} + +func BenchmarkSYNCookie_Validate(b *testing.B) { + var sc SYNCookieJar + rng := rand.New(rand.NewSource(1)) + sc.Reset(SYNCookieConfig{Rand: rng, MaxCounterDelta: 2}) + + srcAddr := []byte{192, 168, 1, 100} + dstAddr := []byte{10, 0, 0, 1} + srcPort := uint16(54321) + dstPort := uint16(80) + clientISN := Value(0x12345678) + + cookie := sc.MakeSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN) + ackNum := cookie + 1 + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sc.ValidateSYNCookie(srcAddr, dstAddr, srcPort, dstPort, clientISN, ackNum) + } +}