Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions test/library/encryption/kms/vault_key_rotation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package kms

import (
"context"
"encoding/json"
"fmt"
"os/exec"
"time"

"k8s.io/client-go/kubernetes"
)

const (
// DefaultVaultNamespace is the default namespace where Vault runs
DefaultVaultNamespace = "vault-kms"

// DefaultVaultPodName is the default Vault pod name in StatefulSet
DefaultVaultPodName = "vault-0"

// DefaultTransitKeyName is the default transit encryption key name
DefaultTransitKeyName = "kms-key"

// vaultCommandTimeout is the timeout for vault oc exec commands
// to prevent indefinite blocking in e2e test runs
vaultCommandTimeout = 30 * time.Second
)

// KeyRotator provides an interface for rotating encryption keys.
// This interface allows different key rotation implementations (Vault, etcd, etc.)
// to be used interchangeably in test helpers.
type KeyRotator interface {
// ForceKeyRotation rotates the encryption key and forces immediate use of the new key.
// Returns error if rotation fails.
ForceKeyRotation(ctx context.Context) error
}

// VaultKeyRotator handles Vault transit key rotation operations
type VaultKeyRotator struct {
kubeClient kubernetes.Interface
namespace string
podName string
transitKeyName string
}

// NewVaultKeyRotator creates a new VaultKeyRotator with default values
func NewVaultKeyRotator(kubeClient kubernetes.Interface) *VaultKeyRotator {
return &VaultKeyRotator{
kubeClient: kubeClient,
namespace: DefaultVaultNamespace,
podName: DefaultVaultPodName,
transitKeyName: DefaultTransitKeyName,
}
}

// NewVaultKeyRotatorWithConfig creates a new VaultKeyRotator with custom configuration
func NewVaultKeyRotatorWithConfig(kubeClient kubernetes.Interface, namespace, podName, transitKeyName string) *VaultKeyRotator {
return &VaultKeyRotator{
kubeClient: kubeClient,
namespace: namespace,
podName: podName,
transitKeyName: transitKeyName,
}
}

// ForceKeyRotation implements the KeyRotator interface.
// It rotates the Vault transit encryption key. All old key versions are retained.
//
// Reference: https://developer.hashicorp.com/vault/api-docs/secret/transit#rotate-key
//
// Steps:
// 1. Get initial key version
// 2. Execute 'vault write -f transit/keys/<key-name>/rotate' via oc exec
// 3. Get new key version and validate it increased
func (v *VaultKeyRotator) ForceKeyRotation(ctx context.Context) error {
// Get initial version before rotation
initialVersion, err := v.getCurrentKeyVersion(ctx)
if err != nil {
return fmt.Errorf("failed to get initial key version: %w", err)
}

// Rotate the key
if err := v.rotateKey(ctx); err != nil {
return fmt.Errorf("failed to rotate key: %w", err)
}

// Get the new key version after rotation
newVersion, err := v.getCurrentKeyVersion(ctx)
if err != nil {
return fmt.Errorf("failed to get new key version: %w", err)
}

// Validate that rotation actually happened
if newVersion <= initialVersion {
return fmt.Errorf("rotation failed: version did not increase (before=%d, after=%d)", initialVersion, newVersion)
}

return nil
}

// GetKeyInfo returns information about the transit key including current version and configuration
func (v *VaultKeyRotator) GetKeyInfo(ctx context.Context) (map[string]interface{}, error) {
// Add timeout to prevent indefinite blocking in e2e test runs
commandCtx, cancel := context.WithTimeout(ctx, vaultCommandTimeout)
defer cancel()

// Read key information: vault read transit/keys/<key-name>
cmd := exec.CommandContext(commandCtx, "oc", "exec", v.podName, "-n", v.namespace, "--",
"vault", "read", "-format=json", fmt.Sprintf("transit/keys/%s", v.transitKeyName))

output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to read key info: %w, output: %s", err, string(output))
}

// Parse the JSON output
var result map[string]interface{}
if err := parseJSON(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse key info: %w", err)
}

return result, nil
}

// rotateKey executes the vault key rotation command
func (v *VaultKeyRotator) rotateKey(ctx context.Context) error {
// Add timeout to prevent indefinite blocking in e2e test runs
commandCtx, cancel := context.WithTimeout(ctx, vaultCommandTimeout)
defer cancel()

// Command: vault write -f transit/keys/<key-name>/rotate
// Reference: https://developer.hashicorp.com/vault/api-docs/secret/transit#rotate-key
cmd := exec.CommandContext(commandCtx, "oc", "exec", v.podName, "-n", v.namespace, "--",
"vault", "write", "-f", fmt.Sprintf("transit/keys/%s/rotate", v.transitKeyName))

output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("vault key rotation failed: %w, output: %s", err, string(output))
}

return nil
}

// getCurrentKeyVersion retrieves the current (latest) key version
func (v *VaultKeyRotator) getCurrentKeyVersion(ctx context.Context) (int, error) {
keyInfo, err := v.GetKeyInfo(ctx)
if err != nil {
return 0, err
}

data, ok := keyInfo["data"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected key info format: missing data field")
}

latestVersion, ok := data["latest_version"].(float64)
if !ok {
return 0, fmt.Errorf("unexpected key info format: missing or invalid latest_version")
}

return int(latestVersion), nil
}

// parseJSON is a helper to parse JSON output from vault commands
func parseJSON(data []byte, v interface{}) error {
if len(data) == 0 {
return fmt.Errorf("empty JSON data")
}
return json.Unmarshal(data, v)
}