Files
pixa/internal/encurl/encurl.go
T
clawbot 77d55abb7e
check / check (push) Canceled after 0s
Derive every key from the signing key in one place (closes #315)
seal.DeriveKeys derives the session hash and block keys, the encrypted URL
key and the CSRF key from the signing key, with their salts declared beside
it, and returns them as one seal.Keys. internal/providers calls it once at
startup and provides the value to the fx graph. The session manager, the
encrypted URL generator and the CSRF protection each take their own key.
session.NewManager, encurl.NewGenerator, newCSRFProtect and handlers.New
return no error, as nothing in them can fail now. Each key is derived as
before, with seal.DeriveKey and the same salt. The signing key itself stays
the HMAC key of signed URLs and the login password. README.md says under
signing_key what it protects.

Model: opus-5-5
2026-10-26 14:10:20 +01:00

150 lines
4.2 KiB
Go

// 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
)
// 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 that encrypts its tokens
// with key.
func NewGenerator(key [seal.KeySize]byte) *Generator {
return &Generator{key: key}
}
// Generate creates an encrypted URL token from the request's source and
// transform, each as the request holds it, and from expires; 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, expires time.Time,
) (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 !expires.IsZero() {
p.ExpiresAt = 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, and its
// expiry. A token that never expires gives the zero time.
// Returns ErrExpired if the token has expired, or other errors for invalid tokens.
func (g *Generator) Parse(token string) (*imagerequest.Request, time.Time, 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, time.Time{}, ErrDecryptFailed
}
return nil, time.Time{}, err
}
// CBOR decode
var p payload
err = cbor.Unmarshal(data, &p)
if err != nil {
return nil, time.Time{}, ErrInvalidFormat
}
// Check expiration (0 = never expires)
if p.ExpiresAt != 0 && time.Now().Unix() > p.ExpiresAt {
return nil, time.Time{}, 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, nil
}