package imgcache import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "strconv" "time" ) // Signature errors. var ( ErrSignatureRequired = errors.New("signature required for non-whitelisted host") ErrSignatureInvalid = errors.New("invalid signature") ErrSignatureExpired = 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 } // NewSigner creates a new Signer with the given secret key. func NewSigner(secretKey string) *Signer { return &Signer{ secretKey: []byte(secretKey), } } // Sign generates an HMAC-SHA256 signature for the given image request. // The signature covers: host + path + query + width + height + format + expiration. func (s *Signer) Sign(req *ImageRequest) 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. func (s *Signer) Verify(req *ImageRequest) error { // Check expiration first if req.Expires.IsZero() { return ErrMissingExpiration } if time.Now().After(req.Expires) { return ErrSignatureExpired } // Compute expected signature expected := s.Sign(req) // Constant-time comparison to prevent timing attacks if !hmac.Equal([]byte(req.Signature), []byte(expected)) { return ErrSignatureInvalid } return nil } // buildSignatureData creates the string to be signed. // Format: "host:path:query:width:height:format:expiration" func (s *Signer) buildSignatureData(req *ImageRequest) string { return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d", req.SourceHost, req.SourcePath, req.SourceQuery, req.Size.Width, req.Size.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 *ImageRequest, 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.Size.OriginalSize() { sizeStr = "orig" } else { sizeStr = fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height) } // Build the path path = fmt.Sprintf("/v1/image/%s%s/%s.%s", req.SourceHost, req.SourcePath, sizeStr, req.Format, ) // Append query if present if req.SourceQuery != "" { // The query goes before the size segment in our URL scheme // So we need to rebuild the path path = fmt.Sprintf("/v1/image/%s%s?%s/%s.%s", req.SourceHost, req.SourcePath, req.SourceQuery, sizeStr, req.Format, ) } return path, sig, exp } // ParseSignatureParams extracts signature and expiration from query parameters. func ParseSignatureParams(sig, expStr string) (signature string, expires time.Time, err error) { signature = sig if expStr == "" { return signature, 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 signature, expires, nil }