// 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" "time" ) // 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") ) // Request carries the components an image request signature covers. It is a // standalone type so that this package does not depend on imgcache, keeping // the import edge one-way (imgcache depends on signature, never the reverse). type Request struct { // SourceHost is the origin host (e.g. "cdn.example.com"). SourceHost string // SourcePath is the path on the origin (e.g. "/photos/cat.jpg"). SourcePath string // SourceQuery is the optional query string for the origin URL. SourceQuery string // Width is the requested output width in pixels. Width int // Height is the requested output height in pixels. Height int // Format is the requested output format (e.g. "webp"). Format string // Signature is the HMAC signature to verify. Signature string // Expires is the signature expiration timestamp. Expires time.Time } // 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. func (s *Signer) Sign(req *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) 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 *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 } // buildSignatureData creates the string to be signed. // Format: "host:path:query:width:height:format:expiration" // All components are used verbatim (exact match). No normalization, // suffix matching, or wildcard expansion is performed. func (s *Signer) buildSignatureData(req *Request) string { return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d", req.SourceHost, req.SourcePath, req.SourceQuery, req.Width, req.Height, req.Format, req.Expires.Unix(), ) } // 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 *Request, ttl time.Duration) (path string, sig string, exp 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.Width == 0 && req.Height == 0 { sizeStr = "orig" } else { sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height) } // Build the path. // When a source query is present, it is embedded as a path segment // (e.g. /host/path?query/size.fmt) so that the URL parser can extract // it from the last-slash split. The "?" inside a path segment is // percent-encoded by clients but chi delivers it decoded, which is // exactly what the URL parser expects. if req.SourceQuery != "" { path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s", req.SourceHost, req.SourcePath, url.PathEscape(req.SourceQuery), sizeStr, req.Format, ) } else { path = fmt.Sprintf("/v1/image/%s%s/%s.%s", req.SourceHost, req.SourcePath, sizeStr, req.Format, ) } return path, sig, exp } // ParseParams extracts signature and expiration from query parameters. func ParseParams(sig, expStr string) (parsed string, expires time.Time, err error) { parsed = sig if expStr == "" { return parsed, time.Time{}, nil } expUnix, err := strconv.ParseInt(expStr, 10, 64) if err != nil { return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err) } expires = time.Unix(expUnix, 0) return parsed, expires, nil }