From c033e918f0ce07bf53db56b5cf93a357f10e4bb8 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 07:38:05 -0800 Subject: [PATCH] Add encurl package for encrypted URL tokens CBOR-encoded payloads with NaCl secretbox encryption. Supports expiration, image parameters with omitempty defaults. --- internal/encurl/encurl.go | 158 ++++++++++++++++++ internal/encurl/encurl_test.go | 281 +++++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 internal/encurl/encurl.go create mode 100644 internal/encurl/encurl_test.go diff --git a/internal/encurl/encurl.go b/internal/encurl/encurl.go new file mode 100644 index 0000000..1b36758 --- /dev/null +++ b/internal/encurl/encurl.go @@ -0,0 +1,158 @@ +// Package encurl provides encrypted URL generation and parsing for pixa. +package encurl + +import ( + "errors" + "time" + + "github.com/fxamacker/cbor/v2" + + "sneak.berlin/go/pixa/internal/imgcache" + "sneak.berlin/go/pixa/internal/seal" +) + +// Default values for optional fields. +const ( + DefaultQuality = 85 + DefaultFormat = imgcache.FormatOriginal + DefaultFitMode = imgcache.FitCover + + // HKDF salt for URL encryption key derivation + urlKeySalt = "pixa-urlenc-v1" +) + +// Errors returned by encurl operations. +var ( + ErrExpired = errors.New("encrypted URL has expired") + ErrInvalidFormat = errors.New("invalid encrypted URL format") + ErrDecryptFailed = errors.New("failed to decrypt URL") +) + +// Payload contains all image request parameters in a compact format. +// Field names are short to minimize encoded size. +// Fields with omitempty use sane defaults when absent. +type Payload struct { + SourceHost string `cbor:"h"` // required + SourcePath string `cbor:"p"` // required + SourceQuery string `cbor:"q,omitempty"` // optional + Width int `cbor:"w,omitempty"` // 0 = original + Height int `cbor:"ht,omitempty"` // 0 = original + Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig + Quality int `cbor:"ql,omitempty"` // default: 85 + FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover + ExpiresAt int64 `cbor:"e"` // required, Unix timestamp +} + +// Generator creates and parses encrypted URL tokens. +type Generator struct { + key [seal.KeySize]byte +} + +// NewGenerator creates an encrypted URL generator with a key derived from the signing key. +func NewGenerator(signingKey string) (*Generator, error) { + key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt) + if err != nil { + return nil, err + } + + return &Generator{key: key}, nil +} + +// Generate creates an encrypted URL token from the payload. +// The token is CBOR-encoded, encrypted with NaCl secretbox, and base64url-encoded. +func (g *Generator) Generate(p *Payload) (string, error) { + // CBOR encode the payload + data, err := cbor.Marshal(p) + if err != nil { + return "", err + } + + // Encrypt and base64url encode + return seal.Encrypt(g.key, data) +} + +// Parse decrypts and validates an encrypted URL token. +// Returns ErrExpired if the token has expired, or other errors for invalid tokens. +func (g *Generator) Parse(token string) (*Payload, error) { + // Decrypt + data, err := seal.Decrypt(g.key, token) + if err != nil { + if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) { + return nil, ErrDecryptFailed + } + + return nil, err + } + + // CBOR decode + var p Payload + if err := cbor.Unmarshal(data, &p); err != nil { + return nil, ErrInvalidFormat + } + + // Check expiration + if time.Now().Unix() > p.ExpiresAt { + return nil, ErrExpired + } + + return &p, nil +} + +// ToImageRequest converts the payload to an ImageRequest. +// Applies default values for omitted optional fields. +func (p *Payload) ToImageRequest() *imgcache.ImageRequest { + format := p.Format + if format == "" { + format = DefaultFormat + } + + quality := p.Quality + if quality == 0 { + quality = DefaultQuality + } + + fitMode := p.FitMode + if fitMode == "" { + fitMode = DefaultFitMode + } + + return &imgcache.ImageRequest{ + SourceHost: p.SourceHost, + SourcePath: p.SourcePath, + SourceQuery: p.SourceQuery, + Size: imgcache.Size{ + Width: p.Width, + Height: p.Height, + }, + Format: format, + Quality: quality, + FitMode: fitMode, + } +} + +// FromImageRequest creates a Payload from an ImageRequest with the given expiration. +func FromImageRequest(req *imgcache.ImageRequest, expiresAt time.Time) *Payload { + p := &Payload{ + SourceHost: req.SourceHost, + SourcePath: req.SourcePath, + SourceQuery: req.SourceQuery, + Width: req.Size.Width, + Height: req.Size.Height, + ExpiresAt: expiresAt.Unix(), + } + + // Only set non-default values to benefit from omitempty + if req.Format != "" && req.Format != DefaultFormat { + p.Format = req.Format + } + + if req.Quality != 0 && req.Quality != DefaultQuality { + p.Quality = req.Quality + } + + if req.FitMode != "" && req.FitMode != DefaultFitMode { + p.FitMode = req.FitMode + } + + return p +} diff --git a/internal/encurl/encurl_test.go b/internal/encurl/encurl_test.go new file mode 100644 index 0000000..97256d2 --- /dev/null +++ b/internal/encurl/encurl_test.go @@ -0,0 +1,281 @@ +package encurl + +import ( + "testing" + "time" + + "sneak.berlin/go/pixa/internal/imgcache" +) + +func TestGenerator_GenerateAndParse(t *testing.T) { + gen, err := NewGenerator("test-signing-key-12345") + if err != nil { + t.Fatalf("NewGenerator() error = %v", err) + } + + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + SourceQuery: "v=2", + Width: 800, + Height: 600, + Format: imgcache.FormatWebP, + Quality: 90, + FitMode: imgcache.FitContain, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + + token, err := gen.Generate(payload) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + if token == "" { + t.Fatal("Generate() returned empty token") + } + + parsed, err := gen.Parse(token) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Verify all fields + if parsed.SourceHost != payload.SourceHost { + t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost) + } + if parsed.SourcePath != payload.SourcePath { + t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath) + } + if parsed.SourceQuery != payload.SourceQuery { + t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery) + } + if parsed.Width != payload.Width { + t.Errorf("Width = %d, want %d", parsed.Width, payload.Width) + } + if parsed.Height != payload.Height { + t.Errorf("Height = %d, want %d", parsed.Height, payload.Height) + } + if parsed.Format != payload.Format { + t.Errorf("Format = %q, want %q", parsed.Format, payload.Format) + } + if parsed.Quality != payload.Quality { + t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality) + } + if parsed.FitMode != payload.FitMode { + t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode) + } + if parsed.ExpiresAt != payload.ExpiresAt { + t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt) + } +} + +func TestGenerator_Parse_Expired(t *testing.T) { + gen, _ := NewGenerator("test-signing-key-12345") + + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired + } + + token, err := gen.Generate(payload) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + _, err = gen.Parse(token) + if err == nil { + t.Error("Parse() should fail for expired token") + } + + if err != ErrExpired { + t.Errorf("Parse() error = %v, want %v", err, ErrExpired) + } +} + +func TestGenerator_Parse_InvalidToken(t *testing.T) { + gen, _ := NewGenerator("test-signing-key-12345") + + _, err := gen.Parse("not-a-valid-token") + if err == nil { + t.Error("Parse() should fail for invalid token") + } +} + +func TestGenerator_Parse_TamperedToken(t *testing.T) { + gen, _ := NewGenerator("test-signing-key-12345") + + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + + token, _ := gen.Generate(payload) + + // Tamper with the token + tampered := []byte(token) + if len(tampered) > 10 { + tampered[10] ^= 0x01 + } + + _, err := gen.Parse(string(tampered)) + if err == nil { + t.Error("Parse() should fail for tampered token") + } +} + +func TestGenerator_Parse_WrongKey(t *testing.T) { + gen1, _ := NewGenerator("signing-key-1") + gen2, _ := NewGenerator("signing-key-2") + + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + + token, _ := gen1.Generate(payload) + + _, err := gen2.Parse(token) + if err == nil { + t.Error("Parse() should fail with different signing key") + } +} + +func TestPayload_ToImageRequest(t *testing.T) { + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + SourceQuery: "v=2", + Width: 800, + Height: 600, + Format: imgcache.FormatWebP, + Quality: 90, + FitMode: imgcache.FitContain, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + + req := payload.ToImageRequest() + + if req.SourceHost != payload.SourceHost { + t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost) + } + if req.SourcePath != payload.SourcePath { + t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath) + } + if req.SourceQuery != payload.SourceQuery { + t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery) + } + if req.Size.Width != payload.Width { + t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width) + } + if req.Size.Height != payload.Height { + t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height) + } + if req.Format != payload.Format { + t.Errorf("Format = %q, want %q", req.Format, payload.Format) + } + if req.Quality != payload.Quality { + t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality) + } + if req.FitMode != payload.FitMode { + t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode) + } +} + +func TestPayload_ToImageRequest_Defaults(t *testing.T) { + // Payload with only required fields - should get defaults + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + + req := payload.ToImageRequest() + + if req.Format != DefaultFormat { + t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat) + } + if req.Quality != DefaultQuality { + t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality) + } + if req.FitMode != DefaultFitMode { + t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode) + } +} + +func TestFromImageRequest(t *testing.T) { + req := &imgcache.ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + SourceQuery: "v=2", + Size: imgcache.Size{Width: 800, Height: 600}, + Format: imgcache.FormatWebP, + Quality: 90, + FitMode: imgcache.FitContain, + } + + expiresAt := time.Now().Add(time.Hour) + payload := FromImageRequest(req, expiresAt) + + if payload.SourceHost != req.SourceHost { + t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost) + } + if payload.SourcePath != req.SourcePath { + t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath) + } + if payload.Width != req.Size.Width { + t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width) + } + if payload.ExpiresAt != expiresAt.Unix() { + t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix()) + } +} + +func TestFromImageRequest_OmitsDefaults(t *testing.T) { + // Request with default values - payload should omit them for smaller encoding + req := &imgcache.ImageRequest{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + Format: DefaultFormat, + Quality: DefaultQuality, + FitMode: DefaultFitMode, + } + + payload := FromImageRequest(req, time.Now().Add(time.Hour)) + + // These should be zero/empty because they match defaults + if payload.Format != "" { + t.Errorf("Format should be empty for default, got %q", payload.Format) + } + if payload.Quality != 0 { + t.Errorf("Quality should be 0 for default, got %d", payload.Quality) + } + if payload.FitMode != "" { + t.Errorf("FitMode should be empty for default, got %q", payload.FitMode) + } +} + +func TestGenerator_TokenIsURLSafe(t *testing.T) { + gen, _ := NewGenerator("test-signing-key-12345") + + payload := &Payload{ + SourceHost: "cdn.example.com", + SourcePath: "/images/photo.jpg", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + + token, _ := gen.Generate(payload) + + // Check that token only contains URL-safe characters + for _, c := range token { + isURLSafe := (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' + if !isURLSafe { + t.Errorf("Token contains non-URL-safe character: %q", c) + } + } +}