diff --git a/internal/imgcache/signature.go b/internal/imgcache/signature.go new file mode 100644 index 0000000..892c496 --- /dev/null +++ b/internal/imgcache/signature.go @@ -0,0 +1,139 @@ +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 +} diff --git a/internal/imgcache/signature_test.go b/internal/imgcache/signature_test.go new file mode 100644 index 0000000..46ea58a --- /dev/null +++ b/internal/imgcache/signature_test.go @@ -0,0 +1,295 @@ +package imgcache + +import ( + "testing" + "time" +) + +func TestSigner_Sign(t *testing.T) { + signer := NewSigner("test-secret-key") + + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + SourceQuery: "", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility + } + + sig1 := signer.Sign(req) + sig2 := signer.Sign(req) + + // Same input should produce same signature + if sig1 != sig2 { + t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2) + } + + // Signature should be non-empty + if sig1 == "" { + t.Error("Sign() produced empty signature") + } + + // Different input should produce different signature + req2 := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/dog.jpg", // Different path + SourceQuery: "", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Unix(1704067200, 0), + } + + sig3 := signer.Sign(req2) + if sig1 == sig3 { + t.Error("Sign() produced same signature for different input") + } +} + +func TestSigner_Verify(t *testing.T) { + signer := NewSigner("test-secret-key") + + tests := []struct { + name string + setup func() *ImageRequest + wantErr error + }{ + { + name: "valid signature", + setup: func() *ImageRequest { + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Now().Add(1 * time.Hour), + } + req.Signature = signer.Sign(req) + + return req + }, + wantErr: nil, + }, + { + name: "expired signature", + setup: func() *ImageRequest { + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Now().Add(-1 * time.Hour), // Expired + } + req.Signature = signer.Sign(req) + + return req + }, + wantErr: ErrSignatureExpired, + }, + { + name: "invalid signature", + setup: func() *ImageRequest { + return &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Now().Add(1 * time.Hour), + Signature: "invalid-signature", + } + }, + wantErr: ErrSignatureInvalid, + }, + { + name: "missing expiration", + setup: func() *ImageRequest { + return &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Signature: "some-signature", + // Expires is zero + } + }, + wantErr: ErrMissingExpiration, + }, + { + name: "tampered request", + setup: func() *ImageRequest { + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Now().Add(1 * time.Hour), + } + req.Signature = signer.Sign(req) + // Tamper with the request + req.SourcePath = "/photos/secret.jpg" + + return req + }, + wantErr: ErrSignatureInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.setup() + err := signer.Verify(req) + + if tt.wantErr == nil { + if err != nil { + t.Errorf("Verify() unexpected error = %v", err) + } + } else { + if err != tt.wantErr { + t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr) + } + } + }) + } +} + +func TestSigner_DifferentKeys(t *testing.T) { + signer1 := NewSigner("secret-key-1") + signer2 := NewSigner("secret-key-2") + + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + Expires: time.Now().Add(1 * time.Hour), + } + + // Sign with key 1 + req.Signature = signer1.Sign(req) + + // Verify with key 1 should succeed + if err := signer1.Verify(req); err != nil { + t.Errorf("Verify() with same key failed: %v", err) + } + + // Verify with key 2 should fail + if err := signer2.Verify(req); err != ErrSignatureInvalid { + t.Errorf("Verify() with different key should fail, got: %v", err) + } +} + +func TestGenerateSignedURL(t *testing.T) { + signer := NewSigner("test-secret-key") + + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + SourceQuery: "", + Size: Size{Width: 800, Height: 600}, + Format: FormatWebP, + } + + ttl := 1 * time.Hour + path, sig, exp := signer.GenerateSignedURL(req, ttl) + + // Path should be correct format + expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp" + if path != expectedPath { + t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath) + } + + // Signature should be non-empty + if sig == "" { + t.Error("GenerateSignedURL() produced empty signature") + } + + // Expiration should be approximately now + TTL + expTime := time.Unix(exp, 0) + expectedExp := time.Now().Add(ttl) + if expTime.Sub(expectedExp) > time.Second { + t.Errorf("GenerateSignedURL() exp time off by too much") + } + + // Request should have been updated with signature and expiration + if req.Signature != sig { + t.Errorf("GenerateSignedURL() didn't update request signature") + } +} + +func TestGenerateSignedURL_OrigSize(t *testing.T) { + signer := NewSigner("test-secret-key") + + req := &ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/photos/cat.jpg", + Size: Size{Width: 0, Height: 0}, // Original size + Format: FormatPNG, + } + + path, _, _ := signer.GenerateSignedURL(req, time.Hour) + + expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png" + if path != expectedPath { + t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath) + } +} + +func TestParseSignatureParams(t *testing.T) { + tests := []struct { + name string + sig string + expStr string + wantSig string + wantErr bool + checkTime bool + }{ + { + name: "valid params", + sig: "abc123", + expStr: "1704067200", + wantSig: "abc123", + wantErr: false, + }, + { + name: "empty expiration", + sig: "abc123", + expStr: "", + wantSig: "abc123", + wantErr: false, + }, + { + name: "invalid expiration", + sig: "abc123", + expStr: "not-a-number", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sig, exp, err := ParseSignatureParams(tt.sig, tt.expStr) + + if tt.wantErr { + if err == nil { + t.Error("ParseSignatureParams() expected error, got nil") + } + + return + } + + if err != nil { + t.Errorf("ParseSignatureParams() unexpected error = %v", err) + + return + } + + if sig != tt.wantSig { + t.Errorf("sig = %q, want %q", sig, tt.wantSig) + } + + if tt.expStr != "" && exp.IsZero() { + t.Error("exp should not be zero when expStr is provided") + } + }) + } +}