Files
pixa/internal/signature/signature.go
T
sneak d9b94f5185
check / check (push) Canceled after 0s
Remove the code nothing in production calls (closes #312)
Service.Warm, the ImageCache interface, both GenerateSignedURL methods,
signature.ParseParams, three functions of internal/magic, the image
processor's lists of supported formats, IsEmpty and Count of the
allowlist, ssrfSafeDialer, CacheStale, ErrCacheMiss, ErrNegativeCache,
ImageResponse.LastModified and VariantStorage.Load had no caller outside
tests. They are gone, with the tests that tested only them.

The handlers tests sign their image URLs with generateSignedURL, a test
helper that builds the URL as the removed generator did. The SSRF dialer
tests call dialSSRFSafe with no operator blocklist, which is all
ssrfSafeDialer did. ParseImageURL, MIMEToImageFormat, ContentStorage.Store
and CleanExpired stay, as pixa calls them.

Model: opus-5-5
2026-10-26 09:34:37 +00:00

109 lines
3.4 KiB
Go

// 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.
// 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
}
// 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")
}