From 96e91269e0c05d0b0d9b9ec0658dc754c5e7ee66 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Sep 2019 10:11:59 -0400 Subject: [PATCH 1/3] Implements an Obfuscator as a CookieOption --- roundrobin/obfuscator.go | 23 +++++++++++++++++++++++ roundrobin/stickysessions.go | 22 ++++++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 roundrobin/obfuscator.go diff --git a/roundrobin/obfuscator.go b/roundrobin/obfuscator.go new file mode 100644 index 00000000..b26f4090 --- /dev/null +++ b/roundrobin/obfuscator.go @@ -0,0 +1,23 @@ +package roundrobin + +// Obfuscator is an interface you can pass to NewStickySessionWithObfuscator, +// to encode/encrypt/jumble/whatever your StickySession values +type Obfuscator interface { + // Obfuscate takes a raw string and returns the obfuscated value + Obfuscate(string) string + // Normalize takes an obfuscated string and returns the raw value + Normalize(string) string +} + +// DefaultObfuscator is a no-op that returns the raw/obfuscated strings as-is +type DefaultObfuscator struct{} + +// Obfuscate takes a raw string and returns the obfuscated value +func (o *DefaultObfuscator) Obfuscate(raw string) string { + return raw +} + +// Normalize takes an obfuscated string and returns the raw value +func (o *DefaultObfuscator) Normalize(obfuscated string) string { + return obfuscated +} diff --git a/roundrobin/stickysessions.go b/roundrobin/stickysessions.go index c9013b6b..3eeeddfd 100644 --- a/roundrobin/stickysessions.go +++ b/roundrobin/stickysessions.go @@ -13,8 +13,9 @@ type StickySession struct { // CookieOptions has all the options one would like to set on the affinity cookie type CookieOptions struct { - HTTPOnly bool - Secure bool + HTTPOnly bool + Secure bool + Obfuscator Obfuscator } // NewStickySession creates a new StickySession @@ -39,7 +40,13 @@ func (s *StickySession) GetBackend(req *http.Request, servers []*url.URL) (*url. return nil, false, err } - serverURL, err := url.Parse(cookie.Value) + cookieValue := cookie.Value + if s.options.Obfuscator != nil { + // We have an Obfuscator, let's use it + cookieValue = s.options.Obfuscator.Normalize(cookieValue) + } + + serverURL, err := url.Parse(cookieValue) if err != nil { return nil, false, err } @@ -53,7 +60,14 @@ func (s *StickySession) GetBackend(req *http.Request, servers []*url.URL) (*url. // StickBackend creates and sets the cookie func (s *StickySession) StickBackend(backend *url.URL, w *http.ResponseWriter) { opt := s.options - cookie := &http.Cookie{Name: s.cookieName, Value: backend.String(), Path: "/", HttpOnly: opt.HTTPOnly, Secure: opt.Secure} + + cookieValue := backend.String() + if opt.Obfuscator != nil { + // We have an Obfuscator, let's use it + cookieValue = opt.Obfuscator.Obfuscate(cookieValue) + } + + cookie := &http.Cookie{Name: s.cookieName, Value: cookieValue, Path: "/", HttpOnly: opt.HTTPOnly, Secure: opt.Secure} http.SetCookie(*w, cookie) } From 667e7e4ece8703d13365c48e5833cacf1c3bf3c4 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Sep 2019 10:25:51 -0400 Subject: [PATCH 2/3] Adds a simple hex-based obfuscator --- roundrobin/hex.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 roundrobin/hex.go diff --git a/roundrobin/hex.go b/roundrobin/hex.go new file mode 100644 index 00000000..75f1c1c3 --- /dev/null +++ b/roundrobin/hex.go @@ -0,0 +1,17 @@ +package roundrobin + +import "encoding/hex" + +// HexObfuscator is a roundrobin.Obfuscator that returns an hex-encoded version of the value +type HexObfuscator struct{} + +// Obfuscate takes a raw string and returns the obfuscated value +func (o *HexObfuscator) Obfuscate(raw string) string { + return hex.EncodeToString([]byte(raw)) +} + +// Normalize takes an obfuscated string and returns the raw value +func (o *HexObfuscator) Normalize(obfuscatedStr string) string { + clear, _ := hex.DecodeString(obfuscatedStr) + return string(clear) +} From f481410063c19b3feae1a40aa0c9a90a9eab3de0 Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Sep 2019 10:26:17 -0400 Subject: [PATCH 3/3] Adds an AES-encrypted Obfuscator as a test file, so as not to pollute the main build unless this is of interest. Fully working. Also adds tests. --- roundrobin/aes_test.go | 551 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 roundrobin/aes_test.go diff --git a/roundrobin/aes_test.go b/roundrobin/aes_test.go new file mode 100644 index 00000000..b3e1a305 --- /dev/null +++ b/roundrobin/aes_test.go @@ -0,0 +1,551 @@ +package roundrobin + +import ( + . "github.com/smartystreets/goconvey/convey" + + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "io/ioutil" + "log" + "strconv" + "strings" + "testing" + "time" +) + +var ErrorOut = log.New(ioutil.Discard, "[ERROR] ", 0) + +// AesObfuscator is a roundrobin.Obfuscator that returns an nonceless encrypted version +type AesObfuscator struct { + block cipher.AEAD + ttl time.Duration +} + +// NewAesObfuscator takes a fixed-size key and returns an Obfuscator or an error. +// Key size must be exactly one of 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. +func NewAesObfuscator(key []byte) (Obfuscator, error) { + var a AesObfuscator + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + a.block = aesgcm + + return &a, nil +} + +// NewAesObfuscatorWithExpiration takes a fixed-size key and a TTL, and returns an Obfuscator or an error. +// Key size must be exactly one of 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. +func NewAesObfuscatorWithExpiration(key []byte, ttl time.Duration) (Obfuscator, error) { + var a AesObfuscator + + a.ttl = ttl + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + a.block = aesgcm + + return &a, nil +} + +// Obfuscate takes a raw string and returns the obfuscated value +func (o *AesObfuscator) Obfuscate(raw string) string { + if o.ttl > 0 { + raw = fmt.Sprintf("%s|%d", raw, time.Now().UTC().Add(o.ttl).Unix()) + } + + /* + Nonce is the 64bit nanosecond-resolution time, plus 32bits of crypto/rand, for 96bits (12Bytes). + Theoretically, if 2^32 calls were made in 1 nanosecon, there might be a repeat. + Adds ~765ns, and 4B heap in 1 alloc (Benchmark_NonceTimeRandom4 below) + + Benchmark_NonceRandom12-8 2000000 723 ns/op 16 B/op 1 allocs/op + Benchmark_NonceRandom4-8 2000000 698 ns/op 4 B/op 1 allocs/op + Benchmark_NonceTimeRandom4-8 2000000 765 ns/op 4 B/op 1 allocs/op + */ + nonce := make([]byte, 12) + binary.PutVarint(nonce, time.Now().UnixNano()) + rpend := make([]byte, 4) + if _, err := io.ReadFull(rand.Reader, rpend); err != nil { + // This is a near-impossible error condition on Linux systems. + // An error here means rand.Reader (and thus getrandom(2), and thus /dev/urandom) returned + // less than 4 bytes of data. /dev/urandom is guaranteed to always return the number of + // bytes requested up to 512 bytes on modern kernels. Behaviour on non-Linux systems + // varies, of course. + panic(err) + } + for i := 0; i < 4; i++ { + nonce[i+8] = rpend[i] + } + + obfuscated := o.block.Seal(nil, nonce, []byte(raw), nil) + // We append the 12byte nonce onto the end of the message + obfuscated = append(obfuscated, nonce...) + obfuscatedStr := base64.RawURLEncoding.EncodeToString(obfuscated) + return obfuscatedStr +} + +// Normalize takes an obfuscated string and returns the raw value +func (o *AesObfuscator) Normalize(obfuscatedStr string) string { + obfuscated, err := base64.RawURLEncoding.DecodeString(obfuscatedStr) + if err != nil { + ErrorOut.Printf("AesObfuscator.Normalize Decoding base64 failed with '%s'\n", err) + return "" + } + + // The first len-12 bytes is the ciphertext, the last 12 bytes is the nonce + n := len(obfuscated) - 12 + if n <= 0 { + // Protect against range errors causing panics + ErrorOut.Printf("AesObfuscator.Normalize post-base64-decoded string is too short\n") + return "" + } + + nonce := obfuscated[n:] + obfuscated = obfuscated[:n] + + raw, err := o.block.Open(nil, nonce, []byte(obfuscated), nil) + if err != nil { + // um.... + ErrorOut.Printf("AesObfuscator.Normalize Open failed with '%s'\n", err) + return "" // (badpokerface) + } + if o.ttl > 0 { + rawparts := strings.Split(string(raw), "|") + if len(rawparts) < 2 { + ErrorOut.Printf("AesObfuscator.Normalize TTL set but cookie doesn't contain an expiration: '%s'\n", raw) + return "" // (sadpanda) + } + // validate the ttl + i, err := strconv.ParseInt(rawparts[1], 10, 64) + if err != nil { + ErrorOut.Printf("AesObfuscator.Normalize TTL can't be parsed: '%s'\n", raw) + return "" // (sadpanda) + } + if time.Now().UTC().After(time.Unix(i, 0).UTC()) { + strTime := time.Unix(i, 0).UTC().String() + ErrorOut.Printf("AesObfuscator.Normalize TTL expired: '%s' (%s)\n", raw, strTime) + return "" // (curiousgeorge) + } + raw = []byte(rawparts[0]) + } + return string(raw) +} + +func TestAesObfuscator128(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key", t, func() { + + message := "This is a test" + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a message is obfuscated with it", func() { + obfuscated := o.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("the message is recoverable", func() { + + clear := o.Normalize(obfuscated) + So(clear, ShouldEqual, message) + }) + + }) + + }) +} + +func TestAesObfuscatorBadKey(t *testing.T) { + + Convey("When an AesObfuscator is created with a bad 15byte key, if fails as expected", t, func() { + + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z")) + So(err.Error(), ShouldEqual, "crypto/aes: invalid key size 15") + So(o, ShouldBeNil) + + }) +} + +func TestAesObfuscator128Ttl(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key, and a future TTL", t, func() { + + message := "This is a test" + o, err := NewAesObfuscatorWithExpiration([]byte("95Bx9JkKX3xbd7z3"), 5*time.Second) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a message is obfuscated with it", func() { + obfuscated := o.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("the message is recoverable", func() { + + clear := o.Normalize(obfuscated) + So(clear, ShouldEqual, message) + }) + + }) + + }) +} + +func TestAesObfuscator128TtlFail(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key, and a future TTL", t, func() { + + message := "This is a test" + o, err := NewAesObfuscatorWithExpiration([]byte("95Bx9JkKX3xbd7z3"), 1*time.Second) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a message is obfuscated with it", func() { + obfuscated := o.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("after sleeping past the TTL, the message is NOT recoverable", func() { + time.Sleep(1100 * time.Millisecond) + clear := o.Normalize(obfuscated) + So(clear, ShouldBeEmpty) + }) + + }) + + }) +} + +func TestAesObfuscator128TtlBadExpiration(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key, and a future TTL", t, func() { + + message := "This is a test" + o, err := NewAesObfuscatorWithExpiration([]byte("95Bx9JkKX3xbd7z3"), 5*time.Second) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + no, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + So(err, ShouldBeNil) + So(no, ShouldNotBeNil) + + Convey("and a message is obfuscated with the same key, but no TTL (contrived)", func() { + obfuscated := no.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("the message is not recoverable", func() { + + clear := o.Normalize(obfuscated) + So(clear, ShouldEqual, "") + }) + + }) + + }) +} + +func TestAesObfuscator192(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 24byte key", t, func() { + + message := "This is a test" + o, err := NewAesObfuscator([]byte("cf2nO99ZuWtc4lXsRNONCbp7")) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a message is obfuscated with it", func() { + obfuscated := o.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("the message is recoverable", func() { + + clear := o.Normalize(obfuscated) + So(clear, ShouldEqual, message) + }) + + }) + + }) +} +func TestAesObfuscator256(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 32byte key", t, func() { + + message := "This is a test" + o, err := NewAesObfuscator([]byte("fOFWV7E4fFuj6cvNPHYbCCD0C90dUnQx")) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a message is obfuscated with it", func() { + obfuscated := o.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("the message is recoverable", func() { + + clear := o.Normalize(obfuscated) + So(clear, ShouldEqual, message) + }) + + }) + + }) +} + +func TestAesObfuscatorGarbageNormalized(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key", t, func() { + + message := "sdflsdkjf4wSDfsdfksjd4RSDFFFv" + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a garbage message is Normalized with it, an empty string is returned", func() { + obfuscated := o.Normalize(message) + So(obfuscated, ShouldBeEmpty) + }) + + }) +} + +func TestAesObfuscatorBase64dGarbageNormalized(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key", t, func() { + + message := "aGVsbG8K" + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a garbage message is Normalized with it, an empty string is returned", func() { + obfuscated := o.Normalize(message) + So(obfuscated, ShouldBeEmpty) + }) + + }) +} + +func TestAesObfuscatorFixedNonceNormalized(t *testing.T) { + + Convey("When an AesObfuscator is created with a known 16byte key", t, func() { + + message := "ylFZ5v1JgjWWAJMDXPLLkRiwI3ielRPBSef55he-CVaHV3NYJVgexA" + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + So(err, ShouldBeNil) + So(o, ShouldNotBeNil) + + Convey("and a previously fixed-nonce message is Normalized with it, an empty string is returned", func() { + obfuscated := o.Normalize(message) + So(obfuscated, ShouldBeEmpty) + }) + + }) +} + +func TestHexObfuscator(t *testing.T) { + + Convey("When a HexObfuscator is created", t, func() { + + message := "This is a test" + o := HexObfuscator{} + So(o, ShouldNotBeNil) + + Convey("and a message is obfuscated with it", func() { + obfuscated := o.Obfuscate(message) + So(obfuscated, ShouldNotBeEmpty) + So(obfuscated, ShouldNotEqual, message) + + Convey("the message is recoverable", func() { + + clear := o.Normalize(obfuscated) + So(clear, ShouldEqual, message) + }) + + }) + + }) +} + +func Test_NonceTimeRandom12Uniqish(t *testing.T) { + + t.Skip("Silly long test that barely proves anything") + + list := make(map[string]bool) + for i := 0; i < 10000000; i++ { + nonce := make([]byte, 12) + binary.PutVarint(nonce, time.Now().UnixNano()) + rpend := make([]byte, 4) + if _, err := io.ReadFull(rand.Reader, rpend); err != nil { + panic(err.Error()) + } + for i := 0; i < 4; i++ { + nonce[i+8] = rpend[i] + } + if _, ok := list[string(nonce)]; ok { + t.Fail() + } + list[string(nonce)] = true + } +} + +func Benchmark_AesObfuscator128(b *testing.B) { + + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + if err != nil { + b.Fatalf("Creating new AesObfuscator failed: %s\n", err) + } + + s := "This is a test" + b.ResetTimer() + var os string + for i := 0; i < b.N; i++ { + os = o.Obfuscate(s) + if os == "" { + b.Fail() + } + } +} + +func Benchmark_AesNormalizer128(b *testing.B) { + + o, err := NewAesObfuscator([]byte("95Bx9JkKX3xbd7z3")) + if err != nil { + b.Fatalf("Creating new AesObfuscator failed: %s\n", err) + } + + s := "C7Gr2cONX6h7o8sZzMkHnVHPnLLBxa_gR5GxcV47zpru2rb0qv5B0REz" + var ns string + b.ResetTimer() + for i := 0; i < b.N; i++ { + ns = o.Normalize(s) + if ns == "" { + b.Fail() + } + } +} + +func Benchmark_AesObfuscator128Ttl(b *testing.B) { + + o, err := NewAesObfuscatorWithExpiration([]byte("95Bx9JkKX3xbd7z3"), 5*time.Second) + if err != nil { + b.Fatalf("Creating new AesObfuscator failed: %s\n", err) + } + + s := "This is a test" + var os string + b.ResetTimer() + for i := 0; i < b.N; i++ { + os = o.Obfuscate(s) + if os == "" { + b.Fail() + } + } +} + +func Benchmark_AesNormalizer128Ttl(b *testing.B) { + + o, err := NewAesObfuscatorWithExpiration([]byte("95Bx9JkKX3xbd7z3"), 5*time.Second) + if err != nil { + b.Fatalf("Creating new AesObfuscator failed: %s\n", err) + } + + s := "This is a test" + os := o.Obfuscate(s) + var ns string + b.ResetTimer() + for i := 0; i < b.N; i++ { + ns = o.Normalize(os) + if ns == "" { + b.Fail() + } + } +} + +func Benchmark_HexObfuscator(b *testing.B) { + + o := HexObfuscator{} + + s := "This is a test" + var os string + b.ResetTimer() + for i := 0; i < b.N; i++ { + os = o.Obfuscate(s) + if os == "" { + b.Fail() + } + } +} + +func Benchmark_HexNormalizer(b *testing.B) { + + o := HexObfuscator{} + + s := "5468697320697320612074657374" + var ns string + b.ResetTimer() + for i := 0; i < b.N; i++ { + ns = o.Normalize(s) + if ns == "" { + b.Fail() + } + } +} + +func Benchmark_NonceRandom12(b *testing.B) { + + for i := 0; i < b.N; i++ { + nonce := make([]byte, 12) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + panic(err.Error()) + } + } +} + +func Benchmark_NonceRandom4(b *testing.B) { + + for i := 0; i < b.N; i++ { + nonce := make([]byte, 4) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + panic(err.Error()) + } + } +} + +func Benchmark_NonceTimeRandom4(b *testing.B) { + + for i := 0; i < b.N; i++ { + nonce := make([]byte, 12) + binary.PutVarint(nonce, time.Now().UnixNano()) + rpend := make([]byte, 4) + if _, err := io.ReadFull(rand.Reader, rpend); err != nil { + panic(err.Error()) + } + for i := 0; i < 4; i++ { + nonce[i+8] = rpend[i] + } + } +}