JuliusBrussee/caveman · error

%s must decode to exactly 32 bytes, got %d

Error message

%s must decode to exactly 32 bytes, got %d

What it means

The env variable decoded from base64 successfully but did not yield exactly 32 bytes — AES-256 requires a 256-bit key, and secretbox refuses to pad or derive, since a silently weakened or repeated-key AES-GCM would be a security defect. The message reports the actual decoded length (common wrong sizes: 16 from re-using an AES-128 key, 24, or 33+ from double-encoding).

Source

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

	"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)
		if err != nil {
			return nil, fmt.Errorf("secretbox: KMS encrypt: %w", err)
		}
		return wrapped, nil
	}
	if runtimeenv.IsProduction() {
		return nil, fmt.Errorf("secretbox: production requires CAVE_KMS_PROVIDER=scaleway")

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Generate exactly 32 bytes and base64-encode them: openssl rand -base64 32 (output decodes to 32 bytes).
  2. Verify before deploy: echo -n "$CAVE_LOCAL_ENCRYPTION_KEY" | base64 -d | wc -c must print 32.
  3. If migrating from a non-32-byte key, re-encrypt stored secrets under the new key rather than trying to make the old key fit.

Example fix

# before
export CAVE_LOCAL_ENCRYPTION_KEY="$(head -c 16 /dev/urandom | base64)" # decodes to 16 bytes -> rejected

# after
export CAVE_LOCAL_ENCRYPTION_KEY="$(openssl rand -base64 32)"          # decodes to exactly 32 bytes
Defensive patterns

Strategy: validation

Validate before calling

func localKeySizeOK() bool {
    raw, err := base64.StdEncoding.DecodeString(os.Getenv("CAVE_LOCAL_ENCRYPTION_KEY"))
    return err == nil && len(raw) == 32
}

Try / catch

if _, err := secretbox.Encrypt(pt); err != nil {
    if strings.Contains(err.Error(), "exactly 32 bytes") {
        // replace the key with a 32-byte one and re-encrypt stored data under it
    }
}

Prevention

When it happens

Trigger: Local-mode Encrypt/Decrypt with a key generated for a different algorithm (AES-128: 16 bytes), a raw 36-byte UUID, a 64-byte random value, or a base64 string that was itself base64-encoded once more (48 bytes decoding to a 64-byte base64 payload).

Common situations: Reusing a pre-existing encryption key from another service with a different key size; generating with `head -c 16 /dev/urandom | base64`; misunderstanding that the value must encode exactly 32 raw bytes.

Related errors


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