@@ 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
+}