// 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/imagerequest" "sneak.berlin/go/pixa/internal/seal" ) // Default values for optional fields. const ( DefaultQuality = 85 DefaultFormat = imagerequest.FormatJXL DefaultFitMode = imagerequest.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 imagerequest.Format `cbor:"f,omitempty"` // default: jxl Quality int `cbor:"ql,omitempty"` // default: 85 FitMode imagerequest.Fit `cbor:"fm,omitempty"` // default: cover ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires } // 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 request's source, // transform and expiry, each as the request holds it; an empty format, a // quality of 0 or an empty fit mode is left out of the token, and a zero // Expires makes a token that never expires. The token is CBOR-encoded, // encrypted with NaCl secretbox, and base64url-encoded. func (g *Generator) Generate(req *imagerequest.Request) (string, error) { p := &payload{ SourceHost: req.Source.Host, SourcePath: req.Source.Path, SourceQuery: req.Source.Query, Width: req.Transform.Size.Width, Height: req.Transform.Size.Height, Format: req.Transform.Format, Quality: req.Transform.Quality, FitMode: req.Transform.FitMode, } if !req.Expires.IsZero() { p.ExpiresAt = req.Expires.Unix() } // 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 and returns the // request it holds, with default values for omitted optional fields. A token // that never expires gives the zero Expires. // Returns ErrExpired if the token has expired, or other errors for invalid tokens. func (g *Generator) Parse(token string) (*imagerequest.Request, 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 err = cbor.Unmarshal(data, &p) if err != nil { return nil, ErrInvalidFormat } // Check expiration (0 = never expires) if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt { return nil, ErrExpired } format := p.Format if format == "" { format = DefaultFormat } quality := p.Quality if quality == 0 { quality = DefaultQuality } fitMode := p.FitMode if fitMode == "" { fitMode = DefaultFitMode } var expires time.Time if p.ExpiresAt != 0 { expires = time.Unix(p.ExpiresAt, 0) } return &imagerequest.Request{ Source: imagerequest.Source{ Host: p.SourceHost, Path: p.SourcePath, Query: p.SourceQuery, }, Transform: imagerequest.Transform{ Size: imagerequest.Size{Width: p.Width, Height: p.Height}, Format: format, Quality: quality, FitMode: fitMode, }, Expires: expires, }, nil }