~netlandish/gobwebs

4517c2edc5df387064d2d90e8f24696f757e0660 — Peter Sanchez 1 year, 10 months ago 40ce16c
Adding base cookies helpers. Thanks Alex Edwards
1 files changed, 279 insertions(+), 0 deletions(-)

A cookies/cookies.go
A cookies/cookies.go => cookies/cookies.go +279 -0
@@ 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
}