feat: include quality and fit in the URL signature (closes #60) #116

Open
clawbot wants to merge 2 commits from issue-60-sign-quality-fit into next
4 changed files with 147 additions and 10 deletions
Showing only changes of commit 460c11a7bf - Show all commits
+12 -5
View File
@@ -75,7 +75,7 @@ or partial matching is supported.
**Signed data format** (colon-separated):
```
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
```
Where:
@@ -87,13 +87,20 @@ Where:
- `height` — requested height in pixels, `0` for original
- `format` — output format (jpeg, png, webp, avif, gif, orig)
- `expiration` — Unix timestamp when signature expires
- `quality` — output quality 1-100; sign `85` (the default) when the URL
omits the `q` parameter
- `fit` — fit mode (cover, contain, fill, inside, outside); sign `cover`
(the default) when the URL omits the `fit` parameter
**Example:** resize
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
expiration 1704067200:
The `q` and `fit` query parameters are covered by the signature. A URL
signed for one quality or fit value will not verify when replayed with a
different value; the effective (post-default) value is what is signed.
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
WebP with expiration 1704067200, default quality and fit:
1. Build input:
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
2. Compute HMAC-SHA256 with your secret key
3. Base64URL-encode the result
4. URL:
+2
View File
@@ -452,6 +452,8 @@ func signatureRequest(req *ImageRequest) *signature.Request {
Width: req.Size.Width,
Height: req.Size.Height,
Format: string(req.Format),
Quality: req.Quality,
FitMode: string(req.FitMode),
Signature: req.Signature,
Expires: req.Expires,
}
@@ -0,0 +1,122 @@
package signature_test
import (
"testing"
"time"
"sneak.berlin/go/pixa/internal/signature"
)
// Fixed inputs for the quality/fit golden vectors. They are independent of
// the constants in golden_test.go so this file pins the current signed
// format on its own.
const (
qfSigningKey = "golden-test-key"
qfExpiresUnix int64 = 1704067200 // 2024-01-01T00:00:00Z
qfFitCover = "cover"
qfFitContain = "contain"
)
type qualityFitGoldenVector struct {
name string
req signature.Request
// wantSignature is the exact base64url (RFC 4648 URL-safe, padded)
// HMAC-SHA256 signature for the request with Expires set to
// qfExpiresUnix, under the signed format
// "host:path:query:width:height:format:expiration:quality:fit".
wantSignature string
}
// qualityFitGoldenVectors returns the known-answer vectors that pin quality
// and fit as signed components. The three default-value vectors use the
// effective quality (85) and fit ("cover") the handler applies when a URL
// omits q and fit, so they are the signatures real signed URLs must carry.
func qualityFitGoldenVectors() []qualityFitGoldenVector {
return []qualityFitGoldenVector{
{
name: "resized, default quality and fit",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800,
Height: 600,
Format: testFormatWebP,
Quality: 85,
FitMode: qfFitCover,
},
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
},
{
name: "resized with query, default quality and fit",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "token=abc&v=2",
Width: 800,
Height: 600,
Format: testFormatWebP,
Quality: 85,
FitMode: qfFitCover,
},
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
},
{
name: "original size, default quality and fit",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 0,
Height: 0,
Format: testFormatPNG,
Quality: 85,
FitMode: qfFitCover,
},
// "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover"
wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=",
},
{
name: "non-default quality and fit",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800,
Height: 600,
Format: testFormatWebP,
Quality: 40,
FitMode: qfFitContain,
},
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain"
wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=",
},
}
}
// TestSigner_GoldenVectors_QualityFit pins the exact HMAC-SHA256 signature
// output for requests that carry quality and fit as signed components. If
// these assertions fail, the signed byte format
// ("host:path:query:width:height:format:expiration:quality:fit") or the
// base64url encoding has changed, breaking every signature already issued.
// Update these constants only as part of a deliberate, documented signature
// format migration.
func TestSigner_GoldenVectors_QualityFit(t *testing.T) {
t.Parallel()
signer := signature.New(qfSigningKey)
for _, tt := range qualityFitGoldenVectors() {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
signReq := tt.req
signReq.Expires = time.Unix(qfExpiresUnix, 0)
gotSignature := signer.Sign(&signReq)
if gotSignature != tt.wantSignature {
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
gotSignature, tt.wantSignature)
}
})
}
}
+11 -5
View File
@@ -65,7 +65,8 @@ func New(secretKey string) *Signer {
}
// Sign generates an HMAC-SHA256 signature for the given request.
// The signature covers: host + path + query + width + height + format + expiration.
// The signature covers: host + path + query + width + height + format +
// expiration + quality + fit.
func (s *Signer) Sign(req *Request) string {
data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey)
@@ -77,7 +78,8 @@ func (s *Signer) Sign(req *Request) string {
// 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.
// (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.
@@ -151,11 +153,13 @@ func (s *Signer) GenerateSignedURL(
}
// buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration"
// Format: "host:path:query:width:height:format:expiration:quality:fit"
// All components are used verbatim (exact match). No normalization,
// suffix matching, or wildcard expansion is performed.
// 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 *Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
req.SourceHost,
req.SourcePath,
req.SourceQuery,
@@ -163,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string {
req.Height,
req.Format,
req.Expires.Unix(),
req.Quality,
req.FitMode,
)
}