golib/auth/expresscookie/expresscookie.go

139 lines
4.3 KiB
Go

package expresscookie
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
const minSecretBytes = 16
var (
// ErrNoSecret indicates that no signing secret was provided.
ErrNoSecret = errors.New("no secret")
// ErrSecretTooShort indicates that a secret has too little entropy.
ErrSecretTooShort = errors.New("secret too short")
// ErrCookieNameEmpty indicates that no cookie name was provided.
ErrCookieNameEmpty = errors.New("cookie name is empty")
)
// Secret is a validated cookie-signing secret.
//
// [NewSecret] is the supported constructor. Converting bytes directly, as in
// Secret(value), bypasses validation and is unsafe for production. Direct
// conversion is retained only for security research and testing; its semantics
// may change, so callers and applications must not depend on it.
type Secret []byte
// NewSecret validates and copies a cookie-signing secret.
//
// Secrets must contain at least 16 bytes. Use a randomly generated secret of
// at least 32 bytes for new applications.
func NewSecret(value []byte) (Secret, error) {
if len(value) == 0 {
return nil, ErrNoSecret
}
if len(value) < minSecretBytes {
return nil, fmt.Errorf("%w: got %d bytes, need at least %d", ErrSecretTooShort, len(value), minSecretBytes)
}
return Secret(bytes.Clone(value)), nil
}
// DecodeHexSecret decodes and validates an APP_SECRET-style hexadecimal cookie key.
func DecodeHexSecret(value string) (Secret, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, ErrNoSecret
}
secret, err := hex.DecodeString(value)
if err != nil {
return nil, fmt.Errorf("decode hex secret: %w", err)
}
return NewSecret(secret)
}
// Cookie is a parsed or signable cookie.
//
// A parsed Cookie is untrusted until Verify succeeds. Verify returns the
// payload even when verification fails; callers must always check its error.
type Cookie struct {
http.Cookie
payload []byte
signature string
}
// New creates a signable cookie from a name, raw payload, and HTTP cookie
// options. The options' Name and Value fields are ignored. It returns
// [ErrCookieNameEmpty] when name is empty.
func New(name string, payload []byte, options http.Cookie) (Cookie, error) {
if name == "" {
return Cookie{}, ErrCookieNameEmpty
}
options.Name = name
return Cookie{Cookie: options, payload: bytes.Clone(payload)}, nil
}
// Sign creates an HttpOnly, Secure, SameSite=Strict HTTP cookie.
func (c Cookie) Sign(secret Secret) Cookie {
if len(secret) == 0 {
panic(ErrNoSecret)
}
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write(c.payload)
sig := base64.RawStdEncoding.EncodeToString(mac.Sum(nil))
c.Cookie.Value = url.QueryEscape("s:" + string(c.payload) + "." + sig)
c.Cookie.HttpOnly = true
c.Cookie.Secure = true
if c.Cookie.SameSite == 0 {
c.Cookie.SameSite = http.SameSiteStrictMode
}
return c
}
// Parse parses the signed value from an HTTP cookie without verifying it.
func Parse(rawValue string) (Cookie, error) {
cookieValue, err := url.QueryUnescape(rawValue)
if err != nil {
return Cookie{}, fmt.Errorf("failed to unescape cookie value: %w", err)
}
if len(cookieValue) < 2 || cookieValue[:2] != "s:" {
return Cookie{}, fmt.Errorf("failed to decode signed cookie: missing 's:' prefix")
}
withoutPrefix := cookieValue[2:]
dotIdx := strings.LastIndex(withoutPrefix, ".")
if dotIdx == -1 {
return Cookie{}, fmt.Errorf("failed to decode signed cookie: missing '.' separator")
}
return Cookie{
Cookie: http.Cookie{Value: rawValue},
payload: []byte(withoutPrefix[:dotIdx]),
signature: withoutPrefix[dotIdx+1:],
}, nil
}
// Verify checks the signature and returns the raw payload.
//
// The payload is returned even when verification fails. Callers must always
// check the error before using it.
func (c Cookie) Verify(secret Secret) ([]byte, error) {
payload := bytes.Clone(c.payload)
if len(secret) == 0 {
return payload, fmt.Errorf("cookie HMAC verification failed: %w", ErrNoSecret)
}
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write(payload)
expected := base64.RawStdEncoding.EncodeToString(mac.Sum(nil))
normalized := strings.NewReplacer("-", "+", "_", "/").Replace(c.signature)
if !hmac.Equal([]byte(expected), []byte(normalized)) {
return payload, fmt.Errorf("cookie HMAC verification failed: signature mismatch")
}
return payload, nil
}