Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions cmd/dims/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
13 changes: 6 additions & 7 deletions docs/docs/configuration/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

:::
:::
40 changes: 31 additions & 9 deletions docs/docs/endpoints/dims5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -65,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

Expand All @@ -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:
Expand All @@ -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

Expand Down
105 changes: 68 additions & 37 deletions internal/core/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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

Expand Down
18 changes: 9 additions & 9 deletions internal/dims/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ 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)
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
}

imageUrl = decryptedUrl
Expand All @@ -41,12 +42,11 @@ func NewRequest(url *url.URL, cmds string, config core.Config) *Request {
// 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
}
}

Expand All @@ -62,7 +62,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 {
Expand Down
6 changes: 4 additions & 2 deletions internal/http/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion internal/v4/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
5 changes: 4 additions & 1 deletion internal/v5/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
dims "github.com/beetlebugorg/go-dims/internal/http"
"log/slog"
"net/http"
"strings"
)

type Request struct {
Expand Down Expand Up @@ -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))

Expand Down
Loading