check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
114 lines
3.5 KiB
Go
114 lines
3.5 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, 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")
|
|
}
|