From 2a0a18d4d42163ddbd945b3d0e8a526f8418d361 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 21 May 2025 16:18:46 -0400 Subject: [PATCH 1/5] Write error directly when setting up request fails --- pkg/dims/handler.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pkg/dims/handler.go b/pkg/dims/handler.go index 9c1ecbf..5697ddd 100644 --- a/pkg/dims/handler.go +++ b/pkg/dims/handler.go @@ -24,9 +24,8 @@ func NewHandler(config core.Config) http.Handler { request, err := v4.NewRequest(r, w, config) if err != nil { - if err := request.SendError(err); err != nil { - slog.Error("error sending error response", "error", err) - } + w.WriteHeader(400) + slog.Error("error parsing request", "error", err) return } @@ -45,9 +44,8 @@ func NewHandler(config core.Config) http.Handler { request, err := v5.NewRequest(r, w, config) if err != nil { - if err := request.SendError(err); err != nil { - slog.Error("error sending error response", "error", err) - } + w.WriteHeader(400) + slog.Error("error parsing request", "error", err) return } From 250712015bd52f5b535712a368e970b295b03212 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 21 May 2025 16:19:22 -0400 Subject: [PATCH 2/5] Return an error when decryption fails --- internal/dims/request.go | 9 ++++----- internal/http/request.go | 6 ++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/dims/request.go b/internal/dims/request.go index 7f30c5f..663b5c3 100644 --- a/internal/dims/request.go +++ b/internal/dims/request.go @@ -7,7 +7,6 @@ import ( "github.com/beetlebugorg/go-dims/internal/core" "github.com/beetlebugorg/go-dims/internal/geometry" "github.com/davidbyttow/govips/v2/vips" - "log/slog" "net/url" "runtime/trace" "strings" @@ -26,13 +25,13 @@ type Request struct { shrinkFactor int } -func NewRequest(url *url.URL, cmds string, config core.Config) *Request { +func NewRequest(url *url.URL, cmds string, config core.Config) (*Request, error) { imageUrl := url.Query().Get("url") eurl := url.Query().Get("eurl") if eurl != "" { - decryptedUrl, err := core.DecryptURL(config.SigningKey, eurl) + decryptedUrl, err := core.DecryptURL(eurl) if err != nil { - slog.Error("DecryptURL failed.", "error", err) + return &Request{}, err } imageUrl = decryptedUrl @@ -62,7 +61,7 @@ func NewRequest(url *url.URL, cmds string, config core.Config) *Request { SignedParams: signedParams, SendContentDisposition: sendContentDisposition, config: config, - } + }, nil } func (r *Request) Config() core.Config { diff --git a/internal/http/request.go b/internal/http/request.go index 5a41134..ee4b7d8 100644 --- a/internal/http/request.go +++ b/internal/http/request.go @@ -34,11 +34,13 @@ func NewRequest(r *http.Request, w http.ResponseWriter, config core.Config) (*Re requestUrl := r.URL cmds := r.PathValue("commands") + request, err := dims.NewRequest(requestUrl, cmds, config) + return &Request{ - Request: *dims.NewRequest(requestUrl, cmds, config), + Request: *request, httpRequest: r, httpResponse: w, - }, nil + }, err } func (r *Request) HashId() string { From 20e16b9814d72657c8561925f1673dfec884db1e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 21 May 2025 16:33:15 -0400 Subject: [PATCH 3/5] Fall back to DIMS_SIGNING_KEY --- cmd/dims/sign.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cmd/dims/sign.go b/cmd/dims/sign.go index a0dcea5..a0b3e9a 100644 --- a/cmd/dims/sign.go +++ b/cmd/dims/sign.go @@ -18,26 +18,24 @@ type SignCmd struct { } func (cmd *SignCmd) Run() error { - var signingKey string + config := core.ReadConfig() + if cmd.KeyFile != "" { keyBytes, err := os.ReadFile(cmd.KeyFile) if err != nil { return err } - signingKey = string(keyBytes) + config.SigningKey = strings.Trim(string(keyBytes), "\n\r ") } else if cmd.KeyFromStdin { keyBytes, err := io.ReadAll(os.Stdin) if err != nil { os.Exit(1) } - signingKey = string(keyBytes) + config.SigningKey = strings.Trim(string(keyBytes), "\n\r ") } - config := core.ReadConfig() - config.SigningKey = strings.Trim(signingKey, "\n\r ") - signedUrl, err := dims.SignUrl(cmd.ImageURL) if err != nil { if strings.Contains(err.Error(), "signing key is required") { From a460f47af63ff1aff389323072874c0f03c5a71e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 21 May 2025 16:59:22 -0400 Subject: [PATCH 4/5] Use HKDF-SHA256 For backward compatibility with mod_dims "sha1:" can be prepended to DIMS_SIGNING_KEY --- docs/docs/configuration/signing.md | 13 ++-- docs/docs/endpoints/dims5.md | 36 ++++++++-- internal/core/encryption.go | 105 +++++++++++++++++++---------- internal/dims/request.go | 2 + internal/v4/request.go | 4 +- internal/v5/request.go | 5 +- pkg/dims/encryption.go | 4 +- 7 files changed, 114 insertions(+), 55 deletions(-) diff --git a/docs/docs/configuration/signing.md b/docs/docs/configuration/signing.md index 11e70d9..5e8dc51 100644 --- a/docs/docs/configuration/signing.md +++ b/docs/docs/configuration/signing.md @@ -23,15 +23,14 @@ If you encounter signature mismatch errors, double-check that: This key is used to validate every incoming image request. If the signature doesn’t match, the request will be rejected. -:::warning +This key is also used to decrypt the `eurl` query parameter. For mod_dims compatibility, prepend +`sha1:` to the key. -Never expose or commit this value to source control. -Treat it like a production secret β€” store it in a secure environment variable, secret manager, or encrypted config. +:::tip -::: - -:::tip Best Practice +Never expose or commit this value to source control. Treat it like a production secret β€” store it in +a secure environment variable, secret manager, or encrypted config. Use at least 32 characters of high-entropy random data - Generate using your password manager or a secure CLI tool -::: \ No newline at end of file +::: diff --git a/docs/docs/endpoints/dims5.md b/docs/docs/endpoints/dims5.md index 2e67309..7b80a80 100644 --- a/docs/docs/endpoints/dims5.md +++ b/docs/docs/endpoints/dims5.md @@ -37,8 +37,6 @@ From the [Getting Started](../installation.md) guide: | `sig` | `6d3dcb6...` | [Signature](../configuration/signing) to verify the request | | `download` | `1` (optional) | Forces the image to download instead of displaying inline (`Content-Disposition`) | ---- - ## πŸ›‘ Error Handling This endpoint will **always try to return an image**, even when something goes wrong. @@ -52,8 +50,6 @@ remains consistent on your page. In some cases it may not be able to match the r for example when a transformation command's argument has a syntax error. In those cases a 512x512 image will be returned. ---- - ## πŸ” Signing All `/v5/dims` requests must be signed to ensure the request has not been tampered with. @@ -77,8 +73,8 @@ Included in signature: - Any query parameter **except** the following: - `sig` (the signature itself) - `url` (the image URL) - - `eurl` (an encoded version of `url`, not used in signing) - - `_keys` (optional debugging/tracking param) + - `eurl` (an encrypted version of `url`, not used in signing) + - `_keys` (additional query parameters to using in signing) - `download` (controls content disposition, excluded from signing) Example: @@ -92,7 +88,33 @@ Signature input becomes: - `https://example.com/image.jpg` - `http://example.com/overlay.png` (value of `overlay`) ---- +## πŸ” `eurl` encryption + +The `eurl` query parameter allows you to encrypt a full image URL, so that it is not exposed in +plaintext. This is useful when you want to: + +- Obscure or protect source URLs (e.g. signed S3 links) +- Watermark images with a URL that should not be visible to users + +### Implementing `eurl` Encryption + +To generate an `eurl` compatible with go-dims, follow these steps: + +1. **Key Derivation** + - Use the HKDF-SHA256 key derivation function to derive a 16-byte (128-bit) AES key from the secret shared in `DIMS_SIGNING_KEY`. + - Use the string `go-dims` for the salt. + +2. **Encryption** + - Use AES-128-GCM to encrypt the original image URL. + - Generate a 12-byte random IV (nonce). + - Encrypt the URL using the derived key and IV. + +3. **Output Format** + - Concatenate the IV, ciphertext, and tag in that order. + - Base64-encode the entire byte sequence. + - The resulting string should be used as the value for the `eurl` parameter. + +Any mismatch in the key, salt, IV size, or output format will result in a decryption failure (`cipher: message authentication failed`). ### βœ… Use the CLI diff --git a/internal/core/encryption.go b/internal/core/encryption.go index b17da5c..bcda20d 100644 --- a/internal/core/encryption.go +++ b/internal/core/encryption.go @@ -3,47 +3,79 @@ package core import ( "crypto/aes" "crypto/cipher" + "crypto/hkdf" "crypto/rand" "crypto/sha1" + "crypto/sha256" "encoding/base64" "encoding/hex" "errors" + "fmt" + "github.com/caarlos0/env/v10" "io" "log/slog" "strings" ) -func EncryptionKey(secretKey string) []byte { - // Step 1: SHA-1 hash the secret key - hash := sha1.Sum([]byte(secretKey)) // returns [20]byte +var encryptionKey []byte +var salt = []byte("go-dims") - // Step 2: Convert the hash to hex - hexEncoded := hex.EncodeToString(hash[:]) // 40 hex chars +func init() { + envConfig := Signing{} + if err := env.Parse(&envConfig); err != nil { + fmt.Printf("%+v\n", err) + } - // Step 3: Use first 16 characters of the hex string as the key - keyFragment := strings.ToUpper(hexEncoded[:16]) - return []byte(keyFragment) + var err error + if encryptionKey, err = deriveKey(envConfig.SigningKey); err != nil { + slog.Error("failed to derive encryption key", "error", err) + return + } } -func EncryptURL(secretKey string, url string) (string, error) { - key := EncryptionKey(secretKey) +func deriveKey(secretKey string) ([]byte, error) { + if strings.HasPrefix(secretKey, "sha1:") { + secret := secretKey[5:] + hash := sha1.Sum([]byte(secret)) // returns [20]byte + hexEncoded := hex.EncodeToString(hash[:]) // 40 hex chars + keyFragment := strings.ToUpper(hexEncoded[:16]) + + return []byte(keyFragment), nil + } else { + hash := sha256.New + secret := secretKey + if strings.HasPrefix(secret, "hkdf:") { + secret = secret[5:] + } + return hkdf.Key(hash, []byte(secret), salt, "", 16) + } +} - encryptedURL, err := EncryptAES128GCM(key, url) +func EncryptURLKey(secretKey string, url string) (string, error) { + key, err := deriveKey(secretKey) if err != nil { return "", err } - return encryptedURL, nil + return EncryptAES128GCM(key, url) } -// DecryptURL decrypts the given eurl string using a derived AES-128-GCM key. -func DecryptURL(secretKey string, base64Eurl string) (string, error) { - key := EncryptionKey(secretKey) +func DecryptURL(url string) (string, error) { + url = strings.ReplaceAll(url, " ", "+") + + return DecryptAES128GCM(encryptionKey, url) +} + +// DecryptURLKey decrypts the given eurl string using a derived AES-128-GCM key. +func DecryptURLKey(secretKey string, url string) (string, error) { + key, err := deriveKey(secretKey) + if err != nil { + return "", err + } - // Handle spaces in base64Eurl - base64Eurl = strings.ReplaceAll(base64Eurl, " ", "+") + url = strings.ReplaceAll(url, " ", "+") - return DecryptAES128GCM(key, base64Eurl) + return DecryptAES128GCM(key, url) } // DecryptAES128GCM takes a base64-encoded ciphertext and decrypts it using AES-128-GCM. @@ -52,22 +84,9 @@ func DecryptAES128GCM(key []byte, base64EncryptedText string) (string, error) { // Decode the base64 input encryptedData, err := base64.StdEncoding.DecodeString(base64EncryptedText) if err != nil { - slog.Error("base64 decode failed.", "data", base64EncryptedText) return "", err } - if len(encryptedData) < 12+16 { - return "", errors.New("invalid encrypted data length") - } - - // Extract IV, ciphertext, and tag - iv := encryptedData[:12] - tag := encryptedData[len(encryptedData)-16:] - ciphertext := encryptedData[12 : len(encryptedData)-16] - - // Concatenate ciphertext and tag for Go's AEAD interface - ciphertextWithTag := append(ciphertext, tag...) - // Create AES cipher block, err := aes.NewCipher(key) if err != nil { @@ -79,6 +98,18 @@ func DecryptAES128GCM(key []byte, base64EncryptedText string) (string, error) { return "", err } + if len(encryptedData) < aesgcm.NonceSize()+block.BlockSize() { + return "", errors.New("invalid encrypted data length") + } + + // Extract IV, ciphertext, and tag + iv := encryptedData[:aesgcm.NonceSize()] + tag := encryptedData[len(encryptedData)-block.BlockSize():] + ciphertext := encryptedData[aesgcm.NonceSize() : len(encryptedData)-block.BlockSize()] + + // Concatenate ciphertext and tag for Go's AEAD interface + ciphertextWithTag := append(ciphertext, tag...) + // Decrypt plaintext, err := aesgcm.Open(nil, iv, ciphertextWithTag, nil) if err != nil { @@ -95,12 +126,6 @@ func EncryptAES128GCM(key []byte, plaintext string) (string, error) { return "", errors.New("key must be 16 bytes for AES-128") } - // Generate a 12-byte IV (nonce) - iv := make([]byte, 12) - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", err - } - // Create AES cipher block, err := aes.NewCipher(key) if err != nil { @@ -112,6 +137,12 @@ func EncryptAES128GCM(key []byte, plaintext string) (string, error) { return "", err } + // Generate a 12-byte IV (nonce) + iv := make([]byte, aesgcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + // Encrypt ciphertext := aesgcm.Seal(nil, iv, []byte(plaintext), nil) // ciphertext includes the tag at the end diff --git a/internal/dims/request.go b/internal/dims/request.go index 663b5c3..aefd1c6 100644 --- a/internal/dims/request.go +++ b/internal/dims/request.go @@ -7,6 +7,7 @@ import ( "github.com/beetlebugorg/go-dims/internal/core" "github.com/beetlebugorg/go-dims/internal/geometry" "github.com/davidbyttow/govips/v2/vips" + "log/slog" "net/url" "runtime/trace" "strings" @@ -31,6 +32,7 @@ func NewRequest(url *url.URL, cmds string, config core.Config) (*Request, error) if eurl != "" { decryptedUrl, err := core.DecryptURL(eurl) if err != nil { + slog.Error("failed to decrypt eurl, ensure DIMS_SIGNING_KEY matches key used to encrypt. For mod_dims compatibility you must prepend 'sha1:' to the key.", "error", err) return &Request{}, err } diff --git a/internal/v4/request.go b/internal/v4/request.go index fce7d85..ebbc0e2 100644 --- a/internal/v4/request.go +++ b/internal/v4/request.go @@ -60,9 +60,11 @@ func (v4 *Request) Validate() bool { } func (v4 *Request) sign(commands, timestamp, imageUrl string, signedParams map[string]string, signingKey string) string { + key := strings.Replace(signingKey, "sha1:", "", 1) + h := md5.New() h.Write([]byte(timestamp)) - h.Write([]byte(signingKey)) + h.Write([]byte(key)) h.Write([]byte(commands)) h.Write([]byte(imageUrl)) diff --git a/internal/v5/request.go b/internal/v5/request.go index 88853ed..eb7a659 100644 --- a/internal/v5/request.go +++ b/internal/v5/request.go @@ -8,6 +8,7 @@ import ( dims "github.com/beetlebugorg/go-dims/internal/http" "log/slog" "net/http" + "strings" ) type Request struct { @@ -49,7 +50,9 @@ func (v5 *Request) Validate() bool { } func (v5 *Request) sign(imageUrl string, signedParams map[string]string, command string, signingKey string) []byte { - mac := hmac.New(sha256.New, []byte(signingKey)) + key := strings.Replace(signingKey, "sha1:", "", 1) + + mac := hmac.New(sha256.New, []byte(key)) mac.Write([]byte(command)) mac.Write([]byte(imageUrl)) diff --git a/pkg/dims/encryption.go b/pkg/dims/encryption.go index 46a3f3d..fbf162f 100644 --- a/pkg/dims/encryption.go +++ b/pkg/dims/encryption.go @@ -5,10 +5,10 @@ import ( ) func EncryptURL(secretKey string, u string) (string, error) { - return core.EncryptURL(secretKey, u) + return core.EncryptURLKey(secretKey, u) } // DecryptURL decrypts the given eurl string using a derived AES-128-GCM key. func DecryptURL(secretKey string, base64Eurl string) (string, error) { - return core.DecryptURL(secretKey, base64Eurl) + return core.DecryptURLKey(secretKey, base64Eurl) } From 86af63186673567df98f8268ec4ffa76a8d85b01 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 21 May 2025 17:07:04 -0400 Subject: [PATCH 5/5] Revert to using _keys for signed parameters This makes it easier to ensure correct ordering when calculating a signature. --- docs/docs/endpoints/dims5.md | 4 ++-- internal/dims/request.go | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/docs/endpoints/dims5.md b/docs/docs/endpoints/dims5.md index 7b80a80..463e756 100644 --- a/docs/docs/endpoints/dims5.md +++ b/docs/docs/endpoints/dims5.md @@ -61,9 +61,9 @@ The signature is a **HMAC-SHA256 hash (32 bytes)** of the following, concatenate 1. The **signing key** 2. The **command path** (no leading or trailing slashes) 3. The **raw image URL** (not URL-encoded) -4. The **values of any signed query parameters**, in iteration order +4. The **values of any additional query parameters** ---- +If additional query parameters are used, they must be provided in the `_keys` query parameter. ### 🧾 Signed Query Parameters diff --git a/internal/dims/request.go b/internal/dims/request.go index aefd1c6..35af7e9 100644 --- a/internal/dims/request.go +++ b/internal/dims/request.go @@ -42,12 +42,11 @@ func NewRequest(url *url.URL, cmds string, config core.Config) (*Request, error) // Signed Parameters // Include all parameters except for the signature, the image URL, and "eurl". var signedParams = make(map[string]string) - for key, _ := range url.Query() { + _keys := url.Query().Get("_keys") + for _, key := range strings.Split(_keys, ",") { value := url.Query().Get(key) - if key != "sig" && key != "eurl" && key != "_keys" && key != "url" && key != "download" { - if value != "" { - signedParams[key] = value - } + if value != "" { + signedParams[key] = value } }