JuliusBrussee/caveman · error

secretbox: production refuses legacy local ciphertext

Error message

secretbox: production refuses legacy local ciphertext

What it means

When Decrypt receives a non-KMS (legacy local AES-GCM) ciphertext in production, it refuses to decrypt it unless CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT is exactly 'true'. This guard prevents production services from silently relying on statically shared local keys, which undermines the KMS migration. It is an explicit migration-safety sentinel.

Solutions

  1. Re-encrypt legacy data with KMS: decrypt it once in a controlled non-production job or with the legacy flag, then store KMS envelopes.
  2. As a short-term bridge, set CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT=true in production AND ensure CAVE_SECRETBOX_KEY holds the exact original local key — then migrate off it.
  3. Identify which producer is still writing local ciphertext and move it to KMS encryption.
  4. Audit data stores for legacy envelopes (they lack the KMS envelope header detected by kms.IsEnvelope) and schedule migration.

Example fix

// before: prod reads legacy row -> secretbox: production refuses legacy local ciphertext
plaintext, err := secretbox.Decrypt(legacyEnvelope)

// after: one-time migration job (temporary opt-out + re-encrypt)
os.Setenv("CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT", "true")
pt, _ := secretbox.Decrypt(legacyEnvelope)
newEnv, _ := secretbox.Encrypt(pt) // KMS envelope now that provider is set
Defensive patterns

Strategy: validation

Validate before calling

func canDecryptLegacyInProduction() error {
    if runtimeenv.IsProduction() &&
        !strings.EqualFold(strings.TrimSpace(os.Getenv("CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT")), "true") {
        // caller should re-encrypt data instead of relying on legacy local keys
        return errors.New("legacy local ciphertext present; migrate to KMS envelopes or set temporary opt-in")
    }
    return nil
}

Try / catch

pt, err := secretbox.Decrypt(envelope)
if err != nil {
    if strings.Contains(err.Error(), "refuses legacy local ciphertext") {
        // route to migration job: decrypt with opt-in once, re-encrypt via KMS
        return migrateToKMSEnvelope(envelope)
    }
    return err
}

Prevention

When it happens

Trigger: Calling Decrypt (or DecryptPayloadKey/ResolveEnvironmentSecret) in production with an envelope that fails kms.IsEnvelope (i.e. legacy locally-encrypted data) while CAVE_KMS_ALLOW_LEGACY_LOCAL_DECRYPT is unset or not 'true'.

Common situations: Reading old database rows or config blobs encrypted before the KMS migration; restoring a backup containing legacy ciphertext into a KMS-enabled production environment; a service partially migrated where some producers still run with local keys.

Understand the failure class

Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/c26b563e84edc980. Report an issue: GitHub.

Appendix: 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 3ee70a1026)