mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 15:05:00 +00:00
139 lines
4.3 KiB
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
|
|
}
|