// Package signature provides HMAC-SHA256 signing and verification of image // requests. package signature import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "net/url" "strconv" "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. // 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) string { data := s.buildSignatureData(req) mac := hmac.New(sha256.New, s.secretKey) mac.Write([]byte(data)) sig := mac.Sum(nil) return base64.URLEncoding.EncodeToString(sig) } // Verify checks if the signature on the request is valid and not expired. // 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) error { // Check expiration first if req.Expires.IsZero() { return ErrMissingExpiration } if time.Now().After(req.Expires) { return ErrExpired } // Compute expected signature expected := s.Sign(req) // Constant-time comparison to prevent timing attacks if !hmac.Equal([]byte(req.Signature), []byte(expected)) { return ErrInvalid } return nil } // GenerateSignedURL creates a complete URL with signature and expiration. // Returns the path portion that should be appended to the base URL. func (s *Signer) GenerateSignedURL( req *imagerequest.Request, ttl time.Duration, ) (string, string, int64) { // Set expiration req.Expires = time.Now().Add(ttl) exp := req.Expires.Unix() // Generate signature sig := s.Sign(req) req.Signature = sig // Build the size component var sizeStr string if req.Transform.Size.Width == 0 && req.Transform.Size.Height == 0 { sizeStr = "orig" } else { sizeStr = fmt.Sprintf("%dx%d", req.Transform.Size.Width, req.Transform.Size.Height) } // Build the path. A source query follows the source path after a "?". // The image route reads the path decoded once, so the whole path is // written in Go's own percent-encoding of a URL path, which writes "?" // as %3F and "%" as %25, and leaves "/", ";", ",", "=" and "&" as they // are. sourcePathAndQuery := req.Source.Path if req.Source.Query != "" { sourcePathAndQuery += "?" + req.Source.Query } unescapedPath := fmt.Sprintf("/v1/image/%s%s/%s.%s", req.Source.Host, sourcePathAndQuery, sizeStr, req.Transform.Format) return (&url.URL{Path: unescapedPath}).EscapedPath(), sig, exp } // 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) 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, req.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") } // ParseParams extracts signature and expiration from query parameters. func ParseParams(sig, expStr string) (string, time.Time, error) { if expStr == "" { return sig, time.Time{}, nil } expUnix, err := strconv.ParseInt(expStr, 10, 64) if err != nil { return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err) } return sig, time.Unix(expUnix, 0), nil }