JuliusBrussee/caveman · error
secretbox: production refuses legacy local ciphertext
Error message
secretbox: production refuses legacy local ciphertext
What it means
secretbox.Decrypt only accepts legacy local AES-GCM ciphertext in non-production runtimes. In production (runtimeenv.IsProduction()) any non-KMS-envelope input is refused outright unless the operator explicitly sets CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT=true. This forces migration off local-key ciphertext before serving production traffic.
Source
Thrown at shared/platform/secretbox/secretbox.go:116
return wrapped, nil
}
return Encrypt(plaintext)
}
// Decrypt reverses Encrypt: it expects nonce(12) || ciphertext+tag.
func Decrypt(envelope []byte) ([]byte, error) {
if kms.IsEnvelope(envelope) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
plaintext, err := kms.Decrypt(ctx, envelope)
if err != nil {
return nil, fmt.Errorf("secretbox: KMS decrypt: %w", err)
}
return plaintext, nil
}
if runtimeenv.IsProduction() &&
!strings.EqualFold(strings.TrimSpace(os.Getenv("CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT")), "true") {
return nil, fmt.Errorf("secretbox: production refuses legacy local ciphertext")
}
keyBytes, err := loadKey()
if err != nil {
return nil, err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("aes-gcm: %w", err)
}
ns := gcm.NonceSize()
if len(envelope) < ns {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ct := envelope[:ns], envelope[ns:]View on GitHub (pinned to 27d5a3981a)
Solutions
- Preferred: run a one-off migration that decrypts legacy blobs and re-encrypts them with secretbox.Encrypt so they become KMS envelopes, then serve production without the escape hatch.
- If you must read legacy data during migration, set CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT=true in the production environment and remove it as soon as migration completes.
- Verify runtimeenv detection (e.g. env markers) is not accidentally classifying a dev box as production.
Example fix
// before: prod boot fails/returns error on legacy rows
pt, err := secretbox.Decrypt(row.Secret)
// after: one-off migration job
if !kms.IsEnvelope(row.Secret) {
pt, err := secretbox.Decrypt(row.Secret) // run with env flag set, in a migration job
if err != nil { return err }
re, err := secretbox.Encrypt(pt) // produces KMS envelope
if err != nil { return err }
row.Secret = re
return db.Save(row).Error
} Defensive patterns
Strategy: validation
Validate before calling
if runtimeenv.IsProduction() && !kms.IsEnvelope(blob) {
return fmt.Errorf("refusing legacy ciphertext in production; migrate to KMS envelope first")
} Try / catch
pt, err := secretbox.Decrypt(blob)
if err != nil && strings.Contains(err.Error(), "production refuses legacy local ciphertext") {
// route to migration path instead of serving
} Prevention
- Run the re-encryption migration in staging before promoting to production.
- Set CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT only for the migration job's environment, never as a standing prod default.
- Alert when non-envelope ciphertext is still present in production data.
When it happens
Trigger: Calling secretbox.Decrypt in a production runtime on bytes that are NOT a KMS envelope (e.g. old nonce(12)||ciphertext+tag blobs encrypted with the local key) while CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT is unset or not 'true'.
Common situations: Upgrading a deployment that stored pre-KMS ciphertext in its database; promoting a staging environment (which tolerated legacy blobs) to production; a backup restored into a prod database still containing old-format secrets.
Related errors
- secretbox: KMS decrypt: %w
- production KMS configuration: %w
- kms: unsupported provider %q
- kms: plaintext exceeds %d bytes
- kms: decode envelope: %w
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/c26b563e84edc980.
Report an issue: GitHub.