// Package signature provides HMAC-SHA256 signing and verification of image // requests. package signature import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "strings" "time" "sneak.berlin/go/pixa/internal/imagerequest" ) // Signature errors. var ( ErrRequired = errors.New("signature required for non-allowlisted host") ErrInvalid = errors.New("invalid signature") ErrExpired = errors.New("signature has expired") ErrMissingExpiration = errors.New("signature expiration is required") ) // Signer handles HMAC-SHA256 signature generation and verification. type Signer struct { secretKey []byte } // New creates a new Signer with the given secret key. func New(secretKey string) *Signer { return &Signer{ secretKey: []byte(secretKey), } } // Sign generates an HMAC-SHA256 signature for the given request, expiring at // expires. The signature covers: host + path + query + width + height + // format + expiration + quality + fit. The quality and fit mode are the // effective values the request resolves to: callers pass the defaults when // the request omits those parameters, so an omitted quality or fit mode signs // identically to that same value stated explicitly. func (s *Signer) Sign(req *imagerequest.Request, expires time.Time) string { data := s.buildSignatureData(req, expires) mac := hmac.New(sha256.New, s.secretKey) mac.Write([]byte(data)) sig := mac.Sum(nil) return base64.URLEncoding.EncodeToString(sig) } // Verify checks that sig is the signature of the request expiring at expires, // and that expires has not passed. // Signatures are exact-match only: every component of the signed data // (host, path, query, dimensions, format, expiration, quality, fit) must // match exactly. // No suffix matching, wildcard matching, or partial matching is supported. // A signature for "cdn.example.com" will NOT verify for "example.com" or // "other.cdn.example.com", and vice versa. func (s *Signer) Verify( req *imagerequest.Request, sig string, expires time.Time, ) error { // Check expiration first if expires.IsZero() { return ErrMissingExpiration } if time.Now().After(expires) { return ErrExpired } // Compute expected signature expected := s.Sign(req, expires) // Constant-time comparison to prevent timing attacks if !hmac.Equal([]byte(sig), []byte(expected)) { return ErrInvalid } return nil } // buildSignatureData creates the string to be signed. // Format: "host:path:query:width:height:format:expiration:quality:fit" // The path and query go through escapePercentAndColon, so that a ":" in // either cannot be taken for the one between them; every other component // is used verbatim. No normalization, suffix matching, or wildcard // expansion is performed. Quality and fit are the effective transform // values, so replaying a signed URL with a different quality or fit mode // fails verification. func (s *Signer) buildSignatureData( req *imagerequest.Request, expires time.Time, ) string { return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s", req.Source.Host, escapePercentAndColon(req.Source.Path), escapePercentAndColon(req.Source.Query), req.Transform.Size.Width, req.Transform.Size.Height, req.Transform.Format, expires.Unix(), req.Transform.Quality, req.Transform.FitMode, ) } // escapePercentAndColon returns s with each "%" written as "%25" and each // ":" as "%3A", so that the result holds no ":". func escapePercentAndColon(s string) string { s = strings.ReplaceAll(s, "%", "%25") return strings.ReplaceAll(s, ":", "%3A") }