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

  1. 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.
  2. 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.
  3. 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

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


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