From 4517c2edc5df387064d2d90e8f24696f757e0660 Mon Sep 17 00:00:00 2001 From: Peter Sanchez Date: Tue, 27 Dec 2022 18:57:56 -0600 Subject: [PATCH] Adding base cookies helpers. Thanks Alex Edwards --- cookies/cookies.go | 279 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 cookies/cookies.go diff --git a/cookies/cookies.go b/cookies/cookies.go new file mode 100644 index 0000000..5e991ae --- /dev/null +++ b/cookies/cookies.go @@ -0,0 +1,279 @@ +package cookies + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +// Base code is heavily taken from this Alex Edwards article: +// https://www.alexedwards.net/blog/working-with-cookies-in-go + +var ( + // ErrValueTooLong ... + ErrValueTooLong = errors.New("cookie value too long") + // ErrInvalidValue ... + ErrInvalidValue = errors.New("invalid cookie value") +) + +// KeyWallet is a simple struct to pass on various keys. The +// reasoning is for cases where you have to change a secret +// key without affecting already encrypted/signed data that's +// in the wild. +// +// The first key in `Keys` should be the current main key +type KeyWallet struct { + Keys [][]byte +} + +// GenerateSecretKey will generate a random 32byte key used for cookie +// signing and/or encryption +func GenerateSecretKey() []byte { + key := make([]byte, 32) + _, err := rand.Read(key) + if err != nil { + // handle error here + } + return key +} + +// Set will set a cookie with no additional processing. +func Set(c echo.Context, cookie *http.Cookie) { + c.SetCookie(cookie) +} + +// Get will get a cookie with no additional processing. +func Get(c echo.Context, name string) (*http.Cookie, error) { + return c.Cookie(name) +} + +// EncodeCookie will base64 encode a cookie value +func EncodeCookie(cookie *http.Cookie) error { + cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value)) + if len(cookie.String()) > 4096 { + return ErrValueTooLong + } + return nil +} + +// DecodeCookie will base64 decode a cookie value +func DecodeCookie(cookie *http.Cookie) error { + value, err := base64.URLEncoding.DecodeString(cookie.Value) + if err != nil { + return ErrInvalidValue + } + cookie.Value = string(value) + return nil +} + +// SetEncode will set a cookie and base64 encode the value +func SetEncode(c echo.Context, cookie *http.Cookie) error { + if err := EncodeCookie(cookie); err != nil { + return err + } + Set(c, cookie) + return nil +} + +// GetEncode will get a cookie and base64 decode the value +func GetEncode(c echo.Context, name string) (*http.Cookie, error) { + cookie, err := Get(c, name) + if err != nil { + return nil, err + } + err = DecodeCookie(cookie) + if err != nil { + return nil, ErrInvalidValue + } + return cookie, nil +} + +// SetSigned will set a cookie and sign the value +func SetSigned(c echo.Context, cookie *http.Cookie, key *KeyWallet) error { + mac := hmac.New(sha256.New, key.Keys[0]) + mac.Write([]byte(cookie.Name)) + mac.Write([]byte(cookie.Value)) + signature := mac.Sum(nil) + + // Prepend the cookie value with the HMAC signature. + cookie.Value = string(signature) + cookie.Value + + if err := EncodeCookie(cookie); err != nil { + return err + } + Set(c, cookie) + return nil +} + +// GetSigned will get a cookie and verify it's signature +func GetSigned(c echo.Context, name string, key *KeyWallet) (*http.Cookie, error) { + cookie, err := Get(c, name) + if err != nil { + return nil, err + } + err = DecodeCookie(cookie) + if err != nil { + return nil, ErrInvalidValue + } + + if len(cookie.Value) < sha256.Size { + return nil, ErrInvalidValue + } + + // Split apart the signature and original cookie value. + signature := cookie.Value[:sha256.Size] + value := cookie.Value[sha256.Size:] + + // Loop through all keys + var found bool + for _, _key := range key.Keys { + //Recalculate the HMAC signature of the cookie name and original value. + mac := hmac.New(sha256.New, _key) + mac.Write([]byte(name)) + mac.Write([]byte(value)) + expectedSignature := mac.Sum(nil) + + // Check that the recalculated signature matches the signature we received + // in the cookie. If they match, we can be confident that the cookie name + // and value haven't been edited by the client. + if !hmac.Equal([]byte(signature), expectedSignature) { + continue + } + cookie.Value = value + found = true + break + } + + if !found { + return nil, ErrInvalidValue + } + + return cookie, nil +} + +// SetEncrypted will set a cookie that is encrypted +func SetEncrypted(c echo.Context, cookie *http.Cookie, key *KeyWallet) error { + // Create a new AES cipher block from the secret key. + block, err := aes.NewCipher(key.Keys[0]) + if err != nil { + return err + } + + // Wrap the cipher block in Galois Counter Mode. + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return err + } + + // Create a unique nonce containing 12 random bytes. + nonce := make([]byte, aesGCM.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return err + } + + // Prepare the plaintext input for encryption. Because we want to + // authenticate the cookie name as well as the value, we make this plaintext + // in the format "{cookie name}:{cookie value}". We use the : character as a + // separator because it is an invalid character for cookie names and + // therefore shouldn't appear in them. + plaintext := fmt.Sprintf("%s:%s", cookie.Name, cookie.Value) + + // Encrypt the data using aesGCM.Seal(). By passing the nonce as the first + // parameter, the encrypted data will be appended to the nonce — meaning + // that the returned encryptedValue variable will be in the format + // "{nonce}{encrypted plaintext data}". + encryptedValue := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + + // Set the cookie value to the encryptedValue. + cookie.Value = string(encryptedValue) + if err := EncodeCookie(cookie); err != nil { + return err + } + Set(c, cookie) + return nil +} + +// GetEncrypted will get an encrypted cookie and decrypt it +func GetEncrypted(c echo.Context, name string, key *KeyWallet) (*http.Cookie, error) { + cookie, err := Get(c, name) + if err != nil { + return nil, err + } + err = DecodeCookie(cookie) + if err != nil { + return nil, ErrInvalidValue + } + + var found bool + for _, _key := range key.Keys { + // Create a new AES cipher block from the secret key. + block, err := aes.NewCipher(_key) + if err != nil { + // XXX Log error here? + continue + } + + // Wrap the cipher block in Galois Counter Mode. + aesGCM, err := cipher.NewGCM(block) + if err != nil { + // XXX Log error here? + continue + } + + // Get the nonce size. + nonceSize := aesGCM.NonceSize() + + // To avoid a potential 'index out of range' panic in the next step, we + // check that the length of the encrypted value is at least the nonce + // size. + if len(cookie.Value) < nonceSize { + return nil, ErrInvalidValue + } + + // Split apart the nonce from the actual encrypted data. + nonce := cookie.Value[:nonceSize] + ciphertext := cookie.Value[nonceSize:] + + // Use aesGCM.Open() to decrypt and authenticate the data. If this fails, + // return a ErrInvalidValue error. + plaintext, err := aesGCM.Open(nil, []byte(nonce), []byte(ciphertext), nil) + if err != nil { + // XXX Log error here? + continue + } + + // The plaintext value is in the format "{cookie name}:{cookie value}". We + // use strings.Cut() to split it on the first ":" character. + expectedName, value, ok := strings.Cut(string(plaintext), ":") + if !ok { + // Decryption worked but value in cookie is invalid + return nil, ErrInvalidValue + } + + // Check that the cookie name is the expected one and hasn't been changed. + if expectedName != name { + // Decryption worked but value in cookie is invalid + return nil, ErrInvalidValue + } + + cookie.Value = value + found = true + break + } + if !found { + return nil, ErrInvalidValue + } + + return cookie, nil +} -- 2.45.2