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
- Generate exactly 32 bytes and base64-encode them: openssl rand -base64 32 (output decodes to 32 bytes).
- Verify before deploy: echo -n "$CAVE_LOCAL_ENCRYPTION_KEY" | base64 -d | wc -c must print 32.
- 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
- Shell check before deploy: echo -n "$CAVE_LOCAL_ENCRYPTION_KEY" | base64 -d | wc -c # must be 32.
- Never pad/derive a wrong-size key to make it fit — regenerate and re-encrypt.
- Document the key format (base64 of exactly 32 raw bytes) wherever the variable is introduced.
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
- %s is not set; cannot encrypt/decrypt secrets
- CAVEMAN_BINARY_SIGNING_PRIVATE_KEY_PEM is required
- native session key random: %w
- %s is not valid base64: %w
- secretbox: KMS encrypt: %w
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/0e59cfe71dd91403.
Report an issue: GitHub.