JuliusBrussee/caveman · error

%s is not set; cannot encrypt/decrypt secrets

Error message

%s is not set; cannot encrypt/decrypt secrets

What it means

secretbox.loadKey found the CAVE_LOCAL_ENCRYPTION_KEY environment variable unset/empty. In non-KMS, non-production mode this variable holds the base64 32-byte AES-256 master key used to seal local secrets; without it Encrypt/Decrypt cannot operate, so they fail fast rather than silently using a null or derived key.

Source

Thrown at shared/platform/secretbox/secretbox.go:39

	"encoding/base64"
	"fmt"
	"os"
	"strings"
	"time"

	"github.com/JuliusBrussee/caveman/shared/platform/kms"
	"github.com/JuliusBrussee/caveman/shared/platform/runtimeenv"
)

// envKey is the name of the environment variable holding the base64-encoded
// 32-byte master key.
const envKey = "CAVE_LOCAL_ENCRYPTION_KEY"

// loadKey reads and validates the 32-byte AES key from the environment.
func loadKey() ([]byte, error) {
	keyB64 := os.Getenv(envKey)
	if keyB64 == "" {
		return nil, fmt.Errorf("%s is not set; cannot encrypt/decrypt secrets", envKey)
	}
	keyBytes, err := base64.StdEncoding.DecodeString(keyB64)
	if err != nil {
		return nil, fmt.Errorf("%s is not valid base64: %w", envKey, err)
	}
	if len(keyBytes) != 32 {
		return nil, fmt.Errorf("%s must decode to exactly 32 bytes, got %d", envKey, len(keyBytes))
	}
	return keyBytes, nil
}

// Encrypt seals plaintext with AES-256-GCM and a fresh random nonce, returning
// nonce(12) || ciphertext+tag as raw bytes.
func Encrypt(plaintext []byte) ([]byte, error) {
	if useKMS() {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		wrapped, err := kms.Encrypt(ctx, plaintext)

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Generate a key and export it: openssl rand -base64 32 (then put it in the project's local env mechanism, e.g. direnv/.env, not in the repo).
  2. Verify with a preflight check at startup that fails fast and names the variable, instead of discovering it mid-request.
  3. In production, set CAVE_KMS_PROVIDER=scaleway so the KMS path is used and the local key is not required.

Example fix

# before
$ ./bin/api   # CAVE_LOCAL_ENCRYPTION_KEY unset -> "CAVE_LOCAL_ENCRYPTION_KEY is not set; ..."

# after
$ export CAVE_LOCAL_ENCRYPTION_KEY="$(openssl rand -base64 32)"
$ echo 'export CAVE_LOCAL_ENCRYPTION_KEY=...' >> .envrc && direnv allow
$ ./bin/api
Defensive patterns

Strategy: validation

Validate before calling

func hasLocalKey() bool { return os.Getenv("CAVE_LOCAL_ENCRYPTION_KEY") != "" }

// At startup (non-KMS, non-prod):
if !useKMSConfigured() && !runtimeenv.IsProduction() && !hasLocalKey() {
    log.Fatal("CAVE_LOCAL_ENCRYPTION_KEY is not set; generate with: openssl rand -base64 32")
}

Try / catch

if _, err := secretbox.Encrypt(pt); err != nil {
    if strings.Contains(err.Error(), "CAVE_LOCAL_ENCRYPTION_KEY is not set") {
        // fail fast with setup instructions; do not fall back to plaintext
    }
}

Prevention

When it happens

Trigger: Running Encrypt or Decrypt (or code that stores/reads encrypted columns, e.g. API tokens or artifact keys) in local/dev mode — useKMS() false, runtimeenv.IsProduction() false — with CAVE_LOCAL_ENCRYPTION_KEY absent from the process env (.env not loaded, wrong shell, fresh clone).

Common situations: New developer machine without the local env file; dotenv loading skipped in a test or cron context; CI job that only sets the variable for some steps; service started from a different unit-manager environment.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/a5b0cd5aa56ab7ea. Report an issue: GitHub.