diff --git a/.env.example b/.env.example index 365bcaa..c4a470d 100644 --- a/.env.example +++ b/.env.example @@ -20,11 +20,33 @@ METRICS_ENABLED=true METRICS_NAMESPACE=secrets # Master keys (Envelope Encryption) -# Generate a new master key using: ./bin/app create-master-key +# Two modes available: KMS Mode (recommended) or Legacy Mode (development only) +# +# === KMS MODE (RECOMMENDED FOR PRODUCTION) === +# Encrypts master keys at rest using external Key Management Service +# Generate a new KMS master key using: ./bin/app create-master-key --kms-provider= --kms-key-uri= +# Rotate master keys using: ./bin/app rotate-master-key --id= +# +# KMS Providers: +# - localsecrets: Local testing (base64key://<32-byte-base64-key>) +# - gcpkms: Google Cloud KMS (gcpkms://projects//locations//keyRings//cryptoKeys/) +# - awskms: AWS KMS (awskms:/// or awskms:///) +# - azurekeyvault: Azure Key Vault (azurekeyvault://.vault.azure.net/keys/) +# - hashivault: HashiCorp Vault (hashivault:///) +# +# KMS_PROVIDER=localsecrets +# KMS_KEY_URI=base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4= +# MASTER_KEYS=default:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04 +# ACTIVE_MASTER_KEY_ID=default +# +# === LEGACY MODE (DEVELOPMENT ONLY) === +# ⚠️ SECURITY WARNING: Master keys stored as plaintext base64 - USE ONLY FOR DEVELOPMENT # Each key must be exactly 32 bytes (256 bits), base64-encoded # Format: id1:base64key1,id2:base64key2 (comma-separated for multiple keys) -# ⚠️ SECURITY WARNING: Store master keys in secrets manager in production -# Never commit master keys to source control +# Generate a new plaintext master key using: ./bin/app create-master-key +# +KMS_PROVIDER= +KMS_KEY_URI= MASTER_KEYS=default:bEu+O/9NOFAsWf1dhVB9aprmumKhhBcE6o7UPVmI43Y= ACTIVE_MASTER_KEY_ID=default diff --git a/AGENTS.md b/AGENTS.md index 33d761f..0c66f4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1057,6 +1057,181 @@ func (s *Server) SetupRouter( **Reference:** `/internal/http/server.go` (SetupRouter method) +## KMS Service Implementation + +The project supports KMS (Key Management Service) integration for encrypting master keys at rest using external providers. KMS functionality follows interface segregation principles with the domain layer defining minimal interfaces and the service layer providing concrete implementations. + +### Interface Segregation Pattern + +**Domain Layer** (`internal/crypto/domain/master_key.go`): +```go +// Minimal interfaces defined by domain - no external dependencies +type KMSService interface { + OpenKeeper(ctx context.Context, keyURI string) (KMSKeeper, error) +} + +type KMSKeeper interface { + Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) + Close() error +} +``` + +**Service Layer** (`internal/crypto/service/kms_service.go`): +- Implements `KMSService` using `gocloud.dev/secrets` +- Imports all KMS provider drivers (gcpkms, awskms, azurekeyvault, hashivault, localsecrets) +- Returns `*secrets.Keeper` which naturally implements `KMSKeeper` (duck typing) + +**Type Compatibility:** +- `*secrets.Keeper` from gocloud.dev implements both `Decrypt()` and `Close()` methods +- No wrapper types needed - direct type assertion works in implementation code + +**Reference:** `/internal/crypto/service/kms_service.go` and `/internal/crypto/domain/master_key.go:114-128` + +### Testing with localsecrets Provider + +**Always use `localsecrets` provider for tests** - no external dependencies or credentials required. + +**Generate test KMS key:** +```go +func generateLocalSecretsKMSKey(t *testing.T) string { + t.Helper() + key := make([]byte, 32) + _, err := rand.Read(key) + require.NoError(t, err) + return "base64key://" + base64.URLEncoding.EncodeToString(key) +} +``` + +**Type assertion for Encrypt method** (not part of domain interface): +```go +keeperInterface, err := kmsService.OpenKeeper(ctx, kmsKeyURI) +require.NoError(t, err) + +// Type assert to access Encrypt method for tests +keeper, ok := keeperInterface.(*secrets.Keeper) +require.True(t, ok, "keeper should be *secrets.Keeper") + +ciphertext, err := keeper.Encrypt(ctx, plaintext) +``` + +**Mock implementations must return copies** to avoid issues when ciphertext is zeroed: +```go +// BAD - returns slice of input (will be zeroed) +func (m *MockKMSKeeper) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { + return ciphertext, nil +} + +// GOOD - returns a copy +func (m *MockKMSKeeper) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { + result := make([]byte, len(ciphertext)) + copy(result, ciphertext) + return result, nil +} +``` + +**Reference:** `/internal/crypto/service/kms_service_test.go` and `/test/integration/api_test.go` (KMS helpers) + +### Error Handling for Close() Calls + +**All `Close()` calls MUST check errors** (enforced by golangci-lint errcheck). + +**Production code pattern** (with logging): +```go +defer func() { + if closeErr := keeper.Close(); closeErr != nil { + logger.Error("failed to close KMS keeper", slog.Any("error", closeErr)) + } +}() +``` + +**Test code pattern** (with assertions): +```go +defer func() { + assert.NoError(t, keeper.Close()) +}() +``` + +**CLI code pattern** (with user-facing message): +```go +defer func() { + if closeErr := keeperInterface.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close KMS keeper: %v\n", closeErr) + } +}() +``` + +**Reference:** `/internal/crypto/domain/master_key.go:213-217` and `/cmd/app/commands/master_key.go:58-62` + +### Memory Safety and Performance + +**Startup-only decryption:** +- KMS operations happen only at application startup +- Master keys decrypted into memory once via `LoadMasterKeyChain()` +- No per-operation KMS calls (performance optimization) + +**Memory cleanup:** +- Master key zeroing handled by existing `MasterKeyChain.Close()` +- KEK chain similarly zeroed via `KekChain.Close()` +- No additional cleanup needed for KMS-decrypted keys + +**Ownership transfer:** +- Decrypted key data ownership transfers to `MasterKeyChain` +- Original slices can be safely reused by KMS keeper +- Domain layer makes defensive copies when needed + +**Reference:** `/internal/crypto/domain/master_key.go:183-285` (loadMasterKeyChainFromKMS) + +### URI Masking for Security + +**Use `maskKeyURI()` to redact sensitive URI components in logs:** + +```go +maskedURI := maskKeyURI(cfg.KMSKeyURI) +logger.Info("opening KMS keeper", + slog.String("kms_provider", cfg.KMSProvider), + slog.String("kms_key_uri", maskedURI), +) +``` + +**Masking examples:** +- `gcpkms://projects/my-project/...` → `gcpkms://projects/***/...` +- `awskms://key-id-123?region=us-east-1` → `awskms://***?region=us-east-1` +- `azurekeyvault://vault.azure.net/keys/mykey` → `azurekeyvault://***` +- `base64key://c2VjcmV0a2V5` → `base64key://***` + +**Purpose:** +- Prevents sensitive key identifiers from appearing in logs +- Preserves provider type and structure for debugging +- Retains query parameters (e.g., region) that are not sensitive + +**Reference:** `/internal/crypto/domain/master_key.go:130-181` (maskKeyURI function) + +### Auto-Detection Mode + +**KMS vs Legacy mode determined by environment variables:** + +```go +// KMS mode: both KMS_PROVIDER and KMS_KEY_URI must be set +if cfg.KMSProvider != "" && cfg.KMSKeyURI != "" { + return loadMasterKeyChainFromKMS(ctx, cfg, kmsService, logger) +} + +// Legacy mode: neither should be set +if cfg.KMSProvider == "" && cfg.KMSKeyURI == "" { + return LoadMasterKeyChainFromEnv() +} + +// Error: inconsistent configuration +return ErrKMSProviderNotSet or ErrKMSKeyURINotSet +``` + +**Validation:** +- Fail fast on inconsistent configuration (one set, one empty) +- Clear error messages indicating which variable is missing +- No silent fallbacks - explicit mode selection + +**Reference:** `/internal/crypto/domain/master_key.go:287-315` (LoadMasterKeyChain) + ## See also - [Repository README](README.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e062d..3a99205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-02-19 + +### Added +- Added KMS-backed master key support with `KMS_PROVIDER` and `KMS_KEY_URI` +- Added `rotate-master-key` CLI command for staged master key rotation +- Added `create-master-key` KMS flags: `--kms-provider` and `--kms-key-uri` +- Added gocloud-based KMS service support for `localsecrets`, Google Cloud KMS, AWS KMS, Azure Key Vault, and HashiCorp Vault + +### Changed +- Master key loading now auto-detects KMS mode vs legacy mode and validates KMS configuration consistency at startup + +### Security +- Added encrypted-at-rest master key workflow through external KMS providers +- Added startup validation and error paths for incomplete KMS configuration and decryption failures + +### Documentation +- Added `docs/releases/v0.6.0.md` release notes and `docs/releases/v0.6.0-upgrade.md` upgrade guide +- Added KMS operations guide: `docs/operations/kms-setup.md` +- Updated CLI and environment variable docs for KMS configuration and master key rotation workflows + ## [0.5.1] - 2026-02-19 ### Fixed diff --git a/README.md b/README.md index 6f2383f..6790d5e 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ Secrets is inspired by **HashiCorp Vault** ❤️, but it is intentionally **muc The default way to run Secrets is the published Docker image: ```bash -docker pull allisson/secrets:v0.5.1 +docker pull allisson/secrets:v0.6.0 ``` -Use pinned tags for reproducible setups. `latest` is also available for fast iteration. +Use pinned tags for reproducible setups. `latest` is available for dev-only fast iteration. Docs release/API metadata source: `docs/metadata.json`. @@ -29,20 +29,21 @@ Then follow the Docker setup guide in [docs/getting-started/docker.md](docs/gett 1. 🐳 **Run with Docker image (recommended)**: [docs/getting-started/docker.md](docs/getting-started/docker.md) 2. 💻 **Run locally for development**: [docs/getting-started/local-development.md](docs/getting-started/local-development.md) -## 🆕 What's New in v0.5.1 +## 🆕 What's New in v0.6.0 -- 🛠️ Fixed master key loading to preserve usable key material while zeroing temporary decoded buffers -- 🧹 Hardened keychain teardown to zero in-memory master keys before clearing chain state -- 🔒 Expanded regression coverage for master key memory lifecycle and close behavior -- 📘 Added release notes: [docs/releases/v0.5.1.md](docs/releases/v0.5.1.md) -- ⬆️ Added upgrade guide: [docs/releases/v0.5.1-upgrade.md](docs/releases/v0.5.1-upgrade.md) -- 📦 Updated pinned Docker docs/examples to `allisson/secrets:v0.5.1` +- ☁️ Added KMS integration for master key encryption at rest (`KMS_PROVIDER`, `KMS_KEY_URI`) +- 🔁 Added `rotate-master-key` CLI command for safer master key lifecycle operations +- 🧭 Added provider-specific KMS setup and migration runbook documentation +- ✅ Added KMS migration checklist: [docs/operations/kms-migration-checklist.md](docs/operations/kms-migration-checklist.md) +- 📘 Added release notes: [docs/releases/v0.6.0.md](docs/releases/v0.6.0.md) +- ⬆️ Added upgrade guide: [docs/releases/v0.6.0-upgrade.md](docs/releases/v0.6.0-upgrade.md) +- 📦 Updated pinned Docker docs/examples to `allisson/secrets:v0.6.0` Release history quick links: -- Current: [v0.5.1 release notes](docs/releases/v0.5.1.md) -- Previous: [v0.5.0 release notes](docs/releases/v0.5.0.md) -- Previous upgrade guide: [v0.5.0 upgrade guide](docs/releases/v0.5.0-upgrade.md) +- Current: [v0.6.0 release notes](docs/releases/v0.6.0.md) +- Previous: [v0.5.1 release notes](docs/releases/v0.5.1.md) +- Previous upgrade guide: [v0.5.1 upgrade guide](docs/releases/v0.5.1-upgrade.md) ## 📚 Docs Map @@ -53,26 +54,28 @@ Release history quick links: - 🧰 **Troubleshooting**: [docs/getting-started/troubleshooting.md](docs/getting-started/troubleshooting.md) - ✅ **Smoke test script**: [docs/getting-started/smoke-test.md](docs/getting-started/smoke-test.md) - 🧪 **CLI commands reference**: [docs/cli/commands.md](docs/cli/commands.md) -- 🚀 **v0.5.1 release notes**: [docs/releases/v0.5.1.md](docs/releases/v0.5.1.md) -- ⬆️ **v0.5.1 upgrade guide**: [docs/releases/v0.5.1-upgrade.md](docs/releases/v0.5.1-upgrade.md) +- 🚀 **v0.6.0 release notes**: [docs/releases/v0.6.0.md](docs/releases/v0.6.0.md) +- ⬆️ **v0.6.0 upgrade guide**: [docs/releases/v0.6.0-upgrade.md](docs/releases/v0.6.0-upgrade.md) - 🔁 **Release compatibility matrix**: [docs/releases/compatibility-matrix.md](docs/releases/compatibility-matrix.md) - **By Topic** -- ⚙️ **Environment variables**: [docs/configuration/environment-variables.md](docs/configuration/environment-variables.md) -- 🏗️ **Architecture concepts**: [docs/concepts/architecture.md](docs/concepts/architecture.md) -- 🔒 **Security model**: [docs/concepts/security-model.md](docs/concepts/security-model.md) -- 📘 **Glossary**: [docs/concepts/glossary.md](docs/concepts/glossary.md) -- 🔑 **Key management operations**: [docs/operations/key-management.md](docs/operations/key-management.md) -- 🔐 **Security hardening**: [docs/operations/security-hardening.md](docs/operations/security-hardening.md) -- 📊 **Monitoring and metrics**: [docs/operations/monitoring.md](docs/operations/monitoring.md) -- 🧯 **Operator drills**: [docs/operations/operator-drills.md](docs/operations/operator-drills.md) -- 🚀 **Production rollout golden path**: [docs/operations/production-rollout.md](docs/operations/production-rollout.md) -- 🚑 **Failure playbooks**: [docs/operations/failure-playbooks.md](docs/operations/failure-playbooks.md) -- 🏭 **Production deployment**: [docs/operations/production.md](docs/operations/production.md) -- 🛠️ **Development and testing**: [docs/development/testing.md](docs/development/testing.md) -- 🗺️ **Docs architecture map**: [docs/development/docs-architecture-map.md](docs/development/docs-architecture-map.md) -- 🤝 **Docs contributing**: [docs/contributing.md](docs/contributing.md) -- 🗒️ **Docs changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) + - ⚙️ **Environment variables**: [docs/configuration/environment-variables.md](docs/configuration/environment-variables.md) + - 🏗️ **Architecture concepts**: [docs/concepts/architecture.md](docs/concepts/architecture.md) + - 🔒 **Security model**: [docs/concepts/security-model.md](docs/concepts/security-model.md) + - 📘 **Glossary**: [docs/concepts/glossary.md](docs/concepts/glossary.md) + - 🔑 **Key management operations**: [docs/operations/key-management.md](docs/operations/key-management.md) + - ☁️ **KMS setup guide**: [docs/operations/kms-setup.md](docs/operations/kms-setup.md) + - ✅ **KMS migration checklist**: [docs/operations/kms-migration-checklist.md](docs/operations/kms-migration-checklist.md) + - 🔐 **Security hardening**: [docs/operations/security-hardening.md](docs/operations/security-hardening.md) + - 📊 **Monitoring and metrics**: [docs/operations/monitoring.md](docs/operations/monitoring.md) + - 🧯 **Operator drills**: [docs/operations/operator-drills.md](docs/operations/operator-drills.md) + - 🚀 **Production rollout golden path**: [docs/operations/production-rollout.md](docs/operations/production-rollout.md) + - 🚑 **Failure playbooks**: [docs/operations/failure-playbooks.md](docs/operations/failure-playbooks.md) + - 🏭 **Production deployment**: [docs/operations/production.md](docs/operations/production.md) + - 🛠️ **Development and testing**: [docs/development/testing.md](docs/development/testing.md) + - 🗺️ **Docs architecture map**: [docs/development/docs-architecture-map.md](docs/development/docs-architecture-map.md) + - 🤝 **Docs contributing**: [docs/contributing.md](docs/contributing.md) + - 🗒️ **Docs changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) Release note location: @@ -102,6 +105,7 @@ All detailed guides include practical use cases and copy/paste-ready examples. ## ✨ What You Get - 🔐 Envelope encryption (`Master Key -> KEK -> DEK -> Secret Data`) +- 🔑 **KMS Integration** for master key encryption at rest (supports Google Cloud KMS, AWS KMS, Azure Key Vault, HashiCorp Vault, and local secrets for testing) - 🚄 Transit encryption (`/v1/transit/keys/*`) for encrypt/decrypt as a service (decrypt input uses `:`; see [Transit API docs](docs/api/transit.md), [create vs rotate](docs/api/transit.md#create-vs-rotate), and [error matrix](docs/api/transit.md#endpoint-error-matrix)) - 🎫 Tokenization API (`/v1/tokenization/*`) for token generation, detokenization, validation, and revocation - 👤 Token-based authentication and policy-based authorization diff --git a/cmd/app/commands/master_key.go b/cmd/app/commands/master_key.go index 32c0b68..31d0f9c 100644 --- a/cmd/app/commands/master_key.go +++ b/cmd/app/commands/master_key.go @@ -1,21 +1,30 @@ package commands import ( + "context" "crypto/rand" "encoding/base64" "fmt" "time" + + cryptoService "github.com/allisson/secrets/internal/crypto/service" ) // RunCreateMasterKey generates a cryptographically secure 32-byte master key for envelope encryption. // Creates the root key used to encrypt all KEKs. Key material is zeroed from memory after encoding. // If keyID is empty, generates a default ID in format "master-key-YYYY-MM-DD". // -// Output format: MASTER_KEYS=":" and ACTIVE_MASTER_KEY_ID="" +// KMS Mode: When kmsProvider and kmsKeyURI are provided, encrypts the master key with KMS before output. +// Legacy Mode: When KMS parameters are empty, outputs plaintext base64-encoded keys (backward compatible). +// +// Output format: +// - Legacy: MASTER_KEYS=":" (DEFAULT) +// - KMS: MASTER_KEYS=":" + KMS_PROVIDER + KMS_KEY_URI // -// Security: Store output securely (secrets manager/KMS), never commit to version control, rotate -// every 90 days. For production, consider using a proper KMS instead of environment variables. -func RunCreateMasterKey(keyID string) error { +// Security: For production, use KMS mode. Legacy mode is for development/testing only. +func RunCreateMasterKey(keyID, kmsProvider, kmsKeyURI string) error { + ctx := context.Background() + // Generate default key ID if not provided if keyID == "" { keyID = fmt.Sprintf("master-key-%s", time.Now().Format("2006-01-02")) @@ -27,24 +36,88 @@ func RunCreateMasterKey(keyID string) error { return fmt.Errorf("failed to generate master key: %w", err) } - // Encode the master key to base64 - encodedKey := base64.StdEncoding.EncodeToString(masterKey) + var encodedKey string + + // Determine mode based on KMS parameters + if kmsProvider != "" || kmsKeyURI != "" { + // KMS mode: validate parameters and encrypt + if kmsProvider == "" || kmsKeyURI == "" { + return fmt.Errorf("both --kms-provider and --kms-key-uri are required for KMS mode") + } + + fmt.Println("# KMS Mode: Encrypting master key with KMS") + fmt.Printf("# KMS Provider: %s\n", kmsProvider) + fmt.Println() + + // Create KMS service and open keeper + kmsService := cryptoService.NewKMSService() + keeperInterface, err := kmsService.OpenKeeper(ctx, kmsKeyURI) + if err != nil { + return fmt.Errorf("failed to open KMS keeper: %w", err) + } + defer func() { + if closeErr := keeperInterface.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close KMS keeper: %v\n", closeErr) + } + }() + + // Type assert to get Encrypt method (needed for encryption) + keeper, ok := keeperInterface.(interface { + Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) + }) + if !ok { + return fmt.Errorf("KMS keeper does not support encryption") + } + + // Encrypt master key with KMS + ciphertext, err := keeper.Encrypt(ctx, masterKey) + if err != nil { + return fmt.Errorf("failed to encrypt master key with KMS: %w", err) + } + + // Encode the ciphertext to base64 + encodedKey = base64.StdEncoding.EncodeToString(ciphertext) + + // Print KMS configuration + fmt.Println("# Master Key Configuration (KMS Mode)") + fmt.Println("# Copy these environment variables to your .env file or secrets manager") + fmt.Println() + fmt.Printf("KMS_PROVIDER=\"%s\"\n", kmsProvider) + fmt.Printf("KMS_KEY_URI=\"%s\"\n", kmsKeyURI) + fmt.Printf("MASTER_KEYS=\"%s:%s\"\n", keyID, encodedKey) + fmt.Printf("ACTIVE_MASTER_KEY_ID=\"%s\"\n", keyID) + fmt.Println() + fmt.Println("# For multiple master keys (key rotation), encrypt each key with the same KMS key:") + fmt.Printf("# MASTER_KEYS=\"%s:%s,new-key:base64-encoded-kms-ciphertext\"\n", keyID, encodedKey) + fmt.Println("# ACTIVE_MASTER_KEY_ID=\"new-key\"") + } else { + // Legacy mode: plaintext base64 encoding + fmt.Println("# Legacy Mode: Generating plaintext master key") + fmt.Println("# WARNING: For production, use KMS mode with --kms-provider and --kms-key-uri") + fmt.Println() + + // Encode the master key to base64 + encodedKey = base64.StdEncoding.EncodeToString(masterKey) + + // Print legacy configuration + fmt.Println("# Master Key Configuration (Legacy Mode - Plaintext)") + fmt.Println("# Copy these environment variables to your .env file or secrets manager") + fmt.Println() + fmt.Printf("MASTER_KEYS=\"%s:%s\"\n", keyID, encodedKey) + fmt.Printf("ACTIVE_MASTER_KEY_ID=\"%s\"\n", keyID) + fmt.Println() + fmt.Println("# For multiple master keys (key rotation), use comma-separated format:") + fmt.Printf("# MASTER_KEYS=\"%s:%s,new-key:base64-encoded-new-key\"\n", keyID, encodedKey) + fmt.Println("# ACTIVE_MASTER_KEY_ID=\"new-key\"") + fmt.Println() + fmt.Println("# For production, consider using KMS mode:") + fmt.Println("# app create-master-key --kms-provider=localsecrets --kms-key-uri=base64key://...") + } // Zero out the master key from memory for security for i := range masterKey { masterKey[i] = 0 } - // Print the environment variable configuration - fmt.Println("# Master Key Configuration") - fmt.Println("# Copy these environment variables to your .env file or secrets manager") - fmt.Println() - fmt.Printf("MASTER_KEYS=\"%s:%s\"\n", keyID, encodedKey) - fmt.Printf("ACTIVE_MASTER_KEY_ID=\"%s\"\n", keyID) - fmt.Println() - fmt.Println("# For multiple master keys (key rotation), use comma-separated format:") - fmt.Printf("# MASTER_KEYS=\"%s:%s,new-key:base64-encoded-new-key\"\n", keyID, encodedKey) - fmt.Println("# ACTIVE_MASTER_KEY_ID=\"new-key\"") - return nil } diff --git a/cmd/app/commands/rotate_master_key.go b/cmd/app/commands/rotate_master_key.go new file mode 100644 index 0000000..0b733e1 --- /dev/null +++ b/cmd/app/commands/rotate_master_key.go @@ -0,0 +1,159 @@ +package commands + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "time" + + "github.com/allisson/secrets/internal/config" + cryptoService "github.com/allisson/secrets/internal/crypto/service" +) + +// RunRotateMasterKey generates a new master key and combines it with existing keys for rotation. +// Reads current MASTER_KEYS from environment, generates a new key, and outputs the combined +// configuration with the new key set as active. The old keys remain accessible for decrypting +// existing KEKs. +// +// Mode Detection: +// - KMS Mode: If KMS_PROVIDER and KMS_KEY_URI are set, encrypts new key with KMS +// - Legacy Mode: If KMS variables are empty, generates plaintext base64-encoded key +// +// Key Rotation Workflow: +// 1. Run this command to generate new master key configuration +// 2. Update environment variables (MASTER_KEYS, ACTIVE_MASTER_KEY_ID) +// 3. Restart application (automatically decrypts KEKs with new master key chain) +// 4. Rotate KEKs: `app rotate-kek --algorithm aes-gcm` +// 5. After all KEKs rotated, remove old master key from MASTER_KEYS +// +// Requirements: MASTER_KEYS and ACTIVE_MASTER_KEY_ID must be set in environment. +func RunRotateMasterKey(ctx context.Context, keyID string) error { + // Load configuration to get KMS settings + cfg := config.Load() + + // Get existing master keys from environment + existingMasterKeys := os.Getenv("MASTER_KEYS") + existingActiveKeyID := os.Getenv("ACTIVE_MASTER_KEY_ID") + + // Validate existing configuration + if existingMasterKeys == "" { + return fmt.Errorf("MASTER_KEYS environment variable is not set - cannot rotate without existing keys") + } + if existingActiveKeyID == "" { + return fmt.Errorf("ACTIVE_MASTER_KEY_ID environment variable is not set") + } + + // Generate default key ID if not provided + if keyID == "" { + keyID = fmt.Sprintf("master-key-%s", time.Now().Format("2006-01-02")) + } + + // Generate a cryptographically secure 32-byte master key + masterKey := make([]byte, 32) + if _, err := rand.Read(masterKey); err != nil { + return fmt.Errorf("failed to generate master key: %w", err) + } + defer func() { + // Zero out the master key from memory for security + for i := range masterKey { + masterKey[i] = 0 + } + }() + + var encodedKey string + var newMasterKeys string + + // Determine mode based on KMS configuration + if cfg.KMSProvider != "" && cfg.KMSKeyURI != "" { + // KMS mode: encrypt new key + fmt.Println("# KMS Mode: Encrypting new master key with KMS") + fmt.Printf("# KMS Provider: %s\n", cfg.KMSProvider) + fmt.Println() + + // Create KMS service and open keeper + kmsService := cryptoService.NewKMSService() + keeperInterface, err := kmsService.OpenKeeper(ctx, cfg.KMSKeyURI) + if err != nil { + return fmt.Errorf("failed to open KMS keeper: %w", err) + } + defer func() { + if closeErr := keeperInterface.Close(); closeErr != nil { + fmt.Printf("Warning: failed to close KMS keeper: %v\n", closeErr) + } + }() + + // Type assert to get Encrypt method + keeper, ok := keeperInterface.(interface { + Encrypt(ctx context.Context, plaintext []byte) ([]byte, error) + }) + if !ok { + return fmt.Errorf("KMS keeper does not support encryption") + } + + // Encrypt master key with KMS + ciphertext, err := keeper.Encrypt(ctx, masterKey) + if err != nil { + return fmt.Errorf("failed to encrypt master key with KMS: %w", err) + } + + // Encode the ciphertext to base64 + encodedKey = base64.StdEncoding.EncodeToString(ciphertext) + + // Combine with existing keys (new key last, will be set as active) + newMasterKeys = fmt.Sprintf("%s,%s:%s", existingMasterKeys, keyID, encodedKey) + + // Print KMS configuration + fmt.Println("# Master Key Rotation (KMS Mode)") + fmt.Println("# Update these environment variables in your .env file or secrets manager") + fmt.Println() + fmt.Printf("KMS_PROVIDER=\"%s\"\n", cfg.KMSProvider) + fmt.Printf("KMS_KEY_URI=\"%s\"\n", cfg.KMSKeyURI) + fmt.Printf("MASTER_KEYS=\"%s\"\n", newMasterKeys) + fmt.Printf("ACTIVE_MASTER_KEY_ID=\"%s\"\n", keyID) + fmt.Println() + fmt.Println("# Rotation Workflow:") + fmt.Println("# 1. Update the above environment variables") + fmt.Println("# 2. Restart the application") + fmt.Println("# 3. Rotate KEKs: app rotate-kek --algorithm aes-gcm") + fmt.Printf( + "# 4. After all KEKs rotated, remove old master key: MASTER_KEYS=\"%s:%s\"\n", + keyID, + encodedKey, + ) + } else { + // Legacy mode: plaintext base64 encoding + fmt.Println("# Legacy Mode: Generating plaintext master key") + fmt.Println("# WARNING: For production, use KMS mode (set KMS_PROVIDER and KMS_KEY_URI)") + fmt.Println() + + // Encode the master key to base64 + encodedKey = base64.StdEncoding.EncodeToString(masterKey) + + // Combine with existing keys (new key last, will be set as active) + newMasterKeys = fmt.Sprintf("%s,%s:%s", existingMasterKeys, keyID, encodedKey) + + // Print legacy configuration + fmt.Println("# Master Key Rotation (Legacy Mode - Plaintext)") + fmt.Println("# Update these environment variables in your .env file or secrets manager") + fmt.Println() + fmt.Printf("MASTER_KEYS=\"%s\"\n", newMasterKeys) + fmt.Printf("ACTIVE_MASTER_KEY_ID=\"%s\"\n", keyID) + fmt.Println() + fmt.Println("# Rotation Workflow:") + fmt.Println("# 1. Update the above environment variables") + fmt.Println("# 2. Restart the application") + fmt.Println("# 3. Rotate KEKs: app rotate-kek --algorithm aes-gcm") + fmt.Printf( + "# 4. After all KEKs rotated, remove old master key: MASTER_KEYS=\"%s:%s\"\n", + keyID, + encodedKey, + ) + fmt.Println() + fmt.Println("# For production, consider using KMS mode:") + fmt.Println("# app create-master-key --kms-provider=localsecrets --kms-key-uri=base64key://...") + } + + return nil +} diff --git a/cmd/app/main.go b/cmd/app/main.go index d83466e..dbfc680 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -41,9 +41,38 @@ func main() { Value: "", Usage: "Master key ID (e.g., prod-master-key-2025)", }, + &cli.StringFlag{ + Name: "kms-provider", + Value: "", + Usage: "KMS provider (localsecrets, gcpkms, awskms, azurekeyvault, hashivault)", + }, + &cli.StringFlag{ + Name: "kms-key-uri", + Value: "", + Usage: "KMS key URI (e.g., base64key://, gcpkms://projects/.../cryptoKeys/...)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return commands.RunCreateMasterKey( + cmd.String("id"), + cmd.String("kms-provider"), + cmd.String("kms-key-uri"), + ) + }, + }, + { + Name: "rotate-master-key", + Usage: "Rotate the Master Key by generating a new key and combining with existing keys", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Aliases: []string{"i"}, + Value: "", + Usage: "New master key ID (e.g., prod-master-key-2026)", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - return commands.RunCreateMasterKey(cmd.String("id")) + return commands.RunRotateMasterKey(ctx, cmd.String("id")) }, }, { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7fc81ac..6f1c42b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,19 @@ > Last updated: 2026-02-19 +## 2026-02-19 (docs v12 - v0.6.0 release prep) + +- Added release notes page: `docs/releases/v0.6.0.md` +- Added upgrade guide: `docs/releases/v0.6.0-upgrade.md` +- Updated docs metadata source (`docs/metadata.json`) to `current_release: v0.6.0` +- Updated root README and docs index to promote `v0.6.0` release links +- Updated operator runbook and production rollout references to `v0.6.0` +- Updated compatibility matrix with `v0.5.1 -> v0.6.0` upgrade path +- Updated pinned Docker image examples from `allisson/secrets:v0.5.1` to `allisson/secrets:v0.6.0` +- Updated CLI command docs for KMS mode flags and new `rotate-master-key` command +- Updated environment variable docs for `KMS_PROVIDER` and `KMS_KEY_URI` configuration +- Updated key management and troubleshooting guides with KMS rotation and failure-mode guidance + ## 2026-02-19 (docs v11 - v0.5.1 patch release prep) - Added release notes page: `docs/releases/v0.5.1.md` diff --git a/docs/README.md b/docs/README.md index 6026e30..536da05 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,8 @@ Welcome to the full documentation for Secrets. Pick a path and dive in 🚀 - 🔒 [concepts/security-model.md](concepts/security-model.md) - 📘 [concepts/glossary.md](concepts/glossary.md) - 🔑 [operations/key-management.md](operations/key-management.md) +- ☁️ [operations/kms-setup.md](operations/kms-setup.md) +- ✅ [operations/kms-migration-checklist.md](operations/kms-migration-checklist.md) - 🚀 [operations/production-rollout.md](operations/production-rollout.md) - 📊 [operations/monitoring.md](operations/monitoring.md) - 🧯 [operations/operator-drills.md](operations/operator-drills.md) @@ -76,8 +78,10 @@ OpenAPI scope note: ## 🚀 Releases -- 📦 [releases/v0.5.1.md](releases/v0.5.1.md) -- ⬆️ [releases/v0.5.1-upgrade.md](releases/v0.5.1-upgrade.md) +- 📦 [releases/v0.6.0.md](releases/v0.6.0.md) +- ⬆️ [releases/v0.6.0-upgrade.md](releases/v0.6.0-upgrade.md) +- 📦 [releases/v0.5.1.md](releases/v0.5.1.md) (historical) +- ⬆️ [releases/v0.5.1-upgrade.md](releases/v0.5.1-upgrade.md) (historical) - 📦 [releases/v0.5.0.md](releases/v0.5.0.md) (historical) - ⬆️ [releases/v0.5.0-upgrade.md](releases/v0.5.0-upgrade.md) (historical) - 🔁 [releases/compatibility-matrix.md](releases/compatibility-matrix.md) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 13db793..48f1a06 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -12,10 +12,10 @@ Local binary: ./bin/app [flags] ``` -Docker image (v0.5.1): +Docker image (v0.6.0): ```bash -docker run --rm --env-file .env allisson/secrets:v0.5.1 [flags] +docker run --rm --env-file .env allisson/secrets:v0.6.0 [flags] ``` ## Core Runtime @@ -33,7 +33,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env -p 8080:8080 allisson/secrets:v0.5.1 server +docker run --rm --network secrets-net --env-file .env -p 8080:8080 allisson/secrets:v0.6.0 server ``` ### `migrate` @@ -49,7 +49,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 migrate ``` ## Key Management @@ -57,21 +57,49 @@ docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 mi ### `create-master-key` Generates a new 32-byte master key and prints `MASTER_KEYS` / `ACTIVE_MASTER_KEY_ID` values. +Supports legacy plaintext mode and KMS mode for encrypted-at-rest master keys. Flags: - `--id`, `-i`: master key ID +- `--kms-provider`: KMS provider (`localsecrets`, `gcpkms`, `awskms`, `azurekeyvault`, `hashivault`) +- `--kms-key-uri`: KMS key URI Local: ```bash ./bin/app create-master-key --id default + +# KMS mode example (recommended for production) +./bin/app create-master-key --id default \ + --kms-provider=localsecrets \ + --kms-key-uri="base64key://" ``` Docker: ```bash -docker run --rm allisson/secrets:v0.5.1 create-master-key --id default +docker run --rm allisson/secrets:v0.6.0 create-master-key --id default +``` + +### `rotate-master-key` + +Generates a new master key, combines it with existing `MASTER_KEYS`, and sets the new key as active. + +Flags: + +- `--id`, `-i`: new master key ID + +Local: + +```bash +./bin/app rotate-master-key --id master-key-2026-08 +``` + +Docker: + +```bash +docker run --rm --env-file .env allisson/secrets:v0.6.0 rotate-master-key --id master-key-2026-08 ``` ### `create-kek` @@ -91,7 +119,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 create-kek --algorithm aes-gcm ``` ### `rotate-kek` @@ -111,11 +139,21 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 rotate-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 rotate-kek --algorithm aes-gcm ``` After master key or KEK rotation, restart API server instances so they load updated key material. +Master key rotation quick sequence: + +```bash +./bin/app rotate-master-key --id master-key-2026-08 +# update env vars from output +# rolling restart API instances +./bin/app rotate-kek --algorithm aes-gcm +# remove old master key from MASTER_KEYS after verification +``` + ## Tokenization ### `create-tokenization-key` @@ -138,7 +176,7 @@ Examples: --deterministic \ --algorithm aes-gcm -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 \ create-tokenization-key --name payment-cards --format luhn-preserving --deterministic --algorithm aes-gcm ``` @@ -162,7 +200,7 @@ Examples: --deterministic \ --algorithm chacha20-poly1305 -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 \ rotate-tokenization-key --name payment-cards --format luhn-preserving --deterministic --algorithm chacha20-poly1305 ``` @@ -186,7 +224,7 @@ Examples: ./bin/app clean-expired-tokens --days 30 --format text # Docker form -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 \ clean-expired-tokens --days 30 --dry-run --format json ``` @@ -269,7 +307,7 @@ Examples: ./bin/app clean-audit-logs --days 90 --format text # Docker form -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 \ clean-audit-logs --days 90 --dry-run --format json ``` diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index f92c3f2..b987a49 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -20,6 +20,8 @@ SERVER_PORT=8080 LOG_LEVEL=info # Master key configuration +KMS_PROVIDER= +KMS_KEY_URI= MASTER_KEYS=default:BASE64_32_BYTE_KEY ACTIVE_MASTER_KEY_ID=default @@ -104,7 +106,12 @@ Logging level. Supported values: `debug`, `info`, `warn`, `error` (default: `inf ### MASTER_KEYS -Comma-separated list of master keys in format `id1:base64key1,id2:base64key2`. +Comma-separated list of master keys in format `id1:value1,id2:value2`. + +Value format depends on mode: + +- Legacy mode: plaintext base64-encoded 32-byte keys +- KMS mode: base64-encoded KMS ciphertext for each 32-byte master key - 📏 Each master key must represent exactly 32 bytes (256 bits) - 🔐 Store in secrets manager, never commit to source control @@ -123,6 +130,50 @@ ID of the master key to use for encrypting new KEKs (default: `default`). - ⭐ Must match one of the IDs in `MASTER_KEYS` - 🔄 After changing `ACTIVE_MASTER_KEY_ID`, restart API servers to load new value +### KMS_PROVIDER + +Optional KMS provider for master key decryption at startup. + +Supported values: + +- `localsecrets` +- `gcpkms` +- `awskms` +- `azurekeyvault` +- `hashivault` + +### KMS_KEY_URI + +KMS key URI for the selected `KMS_PROVIDER`. + +Examples: + +- `base64key://` +- `gcpkms://projects//locations//keyRings//cryptoKeys/` +- `awskms:///` +- `azurekeyvault://.vault.azure.net/keys/` +- `hashivault:///` + +### Master key mode selection + +- KMS mode: set both `KMS_PROVIDER` and `KMS_KEY_URI` +- Legacy mode: leave both unset/empty +- Invalid configuration: setting only one of the two variables fails startup + +For provider setup and migration workflow, see [KMS setup guide](../operations/kms-setup.md). + +### KMS preflight checklist + +Run this checklist before rolling to production: + +1. `KMS_PROVIDER` and `KMS_KEY_URI` are both set (or both unset for legacy mode) +2. `MASTER_KEYS` entries match the selected mode: + - KMS mode: all entries are KMS ciphertext + - Legacy mode: all entries are plaintext base64 32-byte keys +3. `ACTIVE_MASTER_KEY_ID` exists in `MASTER_KEYS` +4. Runtime credentials for provider are present and valid +5. Startup logs show successful key loading before traffic cutover + ## Authentication configuration ### AUTH_TOKEN_EXPIRATION_SECONDS @@ -220,12 +271,20 @@ Prefix for all metric names (default: `secrets`). ```bash ./bin/app create-master-key --id default + +# KMS mode (recommended for production) +./bin/app create-master-key --id default \ + --kms-provider=localsecrets \ + --kms-key-uri="base64key://" + +# Rotate master key (combines with existing MASTER_KEYS) +./bin/app rotate-master-key --id master-key-2026-08 ``` Or with Docker image: ```bash -docker run --rm allisson/secrets:latest create-master-key --id default +docker run --rm allisson/secrets:v0.6.0 create-master-key --id default ``` ## See also diff --git a/docs/development/docs-release-checklist.md b/docs/development/docs-release-checklist.md index 8f75d59..de8d9fe 100644 --- a/docs/development/docs-release-checklist.md +++ b/docs/development/docs-release-checklist.md @@ -40,6 +40,13 @@ Use this checklist for each release (`vX.Y.Z`) to keep docs consistent and navig - `docs/README.md` - `docs/operations/runbook-index.md` +### Docker tag consistency rule + +- Use pinned image tags (`allisson/secrets:vX.Y.Z`) in release guides, rollout runbooks, and copy/paste commands + intended for reproducible operations. +- Use `allisson/secrets:latest` only in explicitly marked fast-iteration/dev-only examples. +- In one document, avoid mixing pinned and `latest` tags unless the distinction is explicitly explained. + ## 6) Validation before merge Run: diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 2ae32dd..d4e03fa 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -4,8 +4,8 @@ This is the default way to run Secrets. -For release reproducibility, this guide uses the pinned image tag `allisson/secrets:v0.5.1`. -You can use `allisson/secrets:latest` for fast iteration. +For release reproducibility, this guide uses the pinned image tag `allisson/secrets:v0.6.0`. +For dev-only fast iteration, you can use `allisson/secrets:latest`. **⚠️ Security Warning:** This guide is for **development and testing only**. For production deployments, see [Security Hardening Guide](../operations/security-hardening.md) and [Production Deployment Guide](../operations/production.md). @@ -15,16 +15,16 @@ You can use `allisson/secrets:latest` for fast iteration. - `RATE_LIMIT_ENABLED` default is `true` (per authenticated client) - `CORS_ENABLED` default is `false` -These defaults were introduced in `v0.5.0` and remain unchanged in `v0.5.1`. +These defaults were introduced in `v0.5.0` and remain unchanged in `v0.6.0`. -If upgrading from `v0.5.0`, review [v0.5.1 upgrade guide](../releases/v0.5.1-upgrade.md). +If upgrading from `v0.5.1`, review [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md). ## ⚡ Quickstart Copy Block Use this minimal flow when you just want to get a working instance quickly: ```bash -docker pull allisson/secrets:v0.5.1 +docker pull allisson/secrets:v0.6.0 docker network create secrets-net || true docker run -d --name secrets-postgres --network secrets-net \ @@ -33,19 +33,19 @@ docker run -d --name secrets-postgres --network secrets-net \ -e POSTGRES_DB=mydb \ postgres:16-alpine -docker run --rm allisson/secrets:v0.5.1 create-master-key --id default +docker run --rm allisson/secrets:v0.6.0 create-master-key --id default # copy generated MASTER_KEYS and ACTIVE_MASTER_KEY_ID into .env -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 migrate -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 create-kek --algorithm aes-gcm docker run --rm --name secrets-api --network secrets-net --env-file .env -p 8080:8080 \ - allisson/secrets:v0.5.1 server + allisson/secrets:v0.6.0 server ``` ## 1) Pull the image ```bash -docker pull allisson/secrets:v0.5.1 +docker pull allisson/secrets:v0.6.0 ``` ## 2) Start PostgreSQL @@ -63,7 +63,7 @@ docker run -d --name secrets-postgres --network secrets-net \ ## 3) Generate a master key ```bash -docker run --rm allisson/secrets:v0.5.1 create-master-key --id default +docker run --rm allisson/secrets:v0.6.0 create-master-key --id default ``` Copy the generated values into a local `.env` file. @@ -95,15 +95,15 @@ EOF ## 5) Run migrations and bootstrap KEK ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 migrate -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 create-kek --algorithm aes-gcm ``` ## 6) Start the API server ```bash docker run --rm --name secrets-api --network secrets-net --env-file .env -p 8080:8080 \ - allisson/secrets:v0.5.1 server + allisson/secrets:v0.6.0 server ``` ## 7) Verify @@ -123,7 +123,7 @@ Expected: Use the CLI command to create your first API client and policy set: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 create-client \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 create-client \ --name bootstrap-admin \ --active \ --policies '[{"path":"*","capabilities":["read","write","delete","encrypt","decrypt","rotate"]}]' \ diff --git a/docs/getting-started/local-development.md b/docs/getting-started/local-development.md index 59eb920..e80086b 100644 --- a/docs/getting-started/local-development.md +++ b/docs/getting-started/local-development.md @@ -12,9 +12,9 @@ Use this path if you want to modify the source code and run from your workstatio - `RATE_LIMIT_ENABLED` default is `true` (per authenticated client) - `CORS_ENABLED` default is `false` -These defaults were introduced in `v0.5.0` and remain unchanged in `v0.5.1`. +These defaults were introduced in `v0.5.0` and remain unchanged in `v0.6.0`. -If upgrading from `v0.5.0`, review [v0.5.1 upgrade guide](../releases/v0.5.1-upgrade.md). +If upgrading from `v0.5.1`, review [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md). ## Prerequisites @@ -44,6 +44,12 @@ cp .env.example .env Paste generated `MASTER_KEYS` and `ACTIVE_MASTER_KEY_ID` into `.env`. +For production-oriented local parity testing, use KMS mode: + +```bash +./bin/app create-master-key --id default --kms-provider=localsecrets --kms-key-uri="base64key://" +``` + ## 4) Start PostgreSQL ```bash diff --git a/docs/getting-started/smoke-test.md b/docs/getting-started/smoke-test.md index 299d926..bedc478 100644 --- a/docs/getting-started/smoke-test.md +++ b/docs/getting-started/smoke-test.md @@ -56,5 +56,5 @@ If transit decrypt fails with `422`, see [Troubleshooting](troubleshooting.md#42 - [Docker getting started](docker.md) - [Local development](local-development.md) - [Troubleshooting](troubleshooting.md) -- [v0.5.1 release notes](../releases/v0.5.1.md) +- [v0.6.0 release notes](../releases/v0.6.0.md) - [Curl examples](../examples/curl.md) diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md index 304ff1a..2236c65 100644 --- a/docs/getting-started/troubleshooting.md +++ b/docs/getting-started/troubleshooting.md @@ -15,10 +15,10 @@ Use this quick route before diving into detailed sections: 5. API requests return `429` -> go to `429 Too Many Requests` (rate limiting) 6. Browser calls fail before API handler -> go to `CORS and preflight failures` 7. After rotating keys behavior is stale -> go to `Rotation completed but server still uses old key context` -8. Startup fails with key config errors -> go to `Missing or Invalid Master Keys` +8. Startup fails with key config errors -> go to `Missing or Invalid Master Keys` and `KMS configuration mismatch` 9. Monitoring data is missing -> go to `Metrics Troubleshooting Matrix` 10. Tokenization endpoints fail after upgrade -> go to `Tokenization migration verification` -11. Master key loads but key-dependent crypto fails -> go to `Master key load regression triage (v0.5.1)` +11. Master key loads but key-dependent crypto fails after mixed-version rollout -> go to `Master key load regression triage (historical v0.5.1 fix)` ## 📑 Table of Contents @@ -32,7 +32,10 @@ Use this quick route before diving into detailed sections: - [Database connection failure](#database-connection-failure) - [Migration failure](#migration-failure) - [Missing or Invalid Master Keys](#missing-or-invalid-master-keys) -- [Master key load regression triage (v0.5.1)](#master-key-load-regression-triage-v051) +- [KMS configuration mismatch](#kms-configuration-mismatch) +- [Mode mismatch diagnostics](#mode-mismatch-diagnostics) +- [KMS authentication or decryption failures](#kms-authentication-or-decryption-failures) +- [Master key load regression triage (historical v0.5.1 fix)](#master-key-load-regression-triage-historical-v051-fix) - [Missing KEK](#missing-kek) - [Metrics Troubleshooting Matrix](#metrics-troubleshooting-matrix) - [Tokenization migration verification](#tokenization-migration-verification) @@ -199,17 +202,67 @@ If CORS is disabled or origin is not allowed, browser requests can fail even if - decoded key must be exactly 32 bytes - ensure `ACTIVE_MASTER_KEY_ID` exists in `MASTER_KEYS` -## Master key load regression triage (v0.5.1) +## KMS configuration mismatch + +- Symptom: startup fails with errors indicating `KMS_PROVIDER` or `KMS_KEY_URI` is missing +- Likely cause: only one KMS variable is set +- Fix: + - KMS mode requires both `KMS_PROVIDER` and `KMS_KEY_URI` + - legacy mode requires both values unset/empty + - verify `.env` and deployment secret injection order + +## Mode mismatch diagnostics + +Use these quick checks when startup errors suggest key mode mismatch: + +```bash +# 1) Check selected mode variables +env | grep -E '^(KMS_PROVIDER|KMS_KEY_URI|ACTIVE_MASTER_KEY_ID|MASTER_KEYS)=' + +# 2) Confirm MASTER_KEYS entry shape +# Legacy mode entries usually look like id: +# KMS mode entries should be ciphertext values produced by your KMS provider + +# 3) Check startup logs for mode and key load behavior +docker logs 2>&1 | grep -E 'KMS mode enabled|master key decrypted via KMS|master key chain loaded' +``` + +Expected patterns: + +- Legacy mode: + - no `KMS mode enabled` log line + - master key chain loads from local config +- KMS mode: + - `KMS mode enabled provider=` + - `master key decrypted via KMS key_id=` for each configured key + +## KMS authentication or decryption failures + +- Symptom: startup fails while opening KMS keeper or decrypting master keys +- Likely cause: invalid KMS credentials, wrong key URI, missing decrypt permissions, or corrupted ciphertext +- Fix: + - verify provider credentials are available in runtime environment + - verify `KMS_KEY_URI` points to the key used to encrypt `MASTER_KEYS` + - confirm KMS IAM/policy includes decrypt permissions + - rotate/regenerate master key entries if ciphertext was truncated or malformed + - use provider setup checks in [KMS setup guide](../operations/kms-setup.md) + +## Master key load regression triage (historical v0.5.1 fix) + +Historical note: + +- This section is retained for mixed-version or rollback investigations involving pre-`v0.5.1` builds. +- For current rollouts, prioritize KMS mode diagnostics and the `v0.6.0` upgrade path. - Symptom: startup succeeds, but key-dependent operations fail unexpectedly after a recent rollout - Likely cause: running a pre-`v0.5.1` build where decoded master key buffers could be zeroed too early - Mixed-version rollout symptom: some requests pass while others fail if old and new images are serving traffic together - Version fingerprint checks: - local binary: `./bin/app --version` - - pinned image check: `docker run --rm allisson/secrets:v0.5.1 --version` + - pinned image check: `docker run --rm allisson/secrets:v0.6.0 --version` - running containers: `docker ps --format 'table {{.Names}}\t{{.Image}}'` - Fix: - - upgrade to `v0.5.1` or newer + - upgrade all instances to `v0.6.0` (or at minimum `v0.5.1+`) - restart API instances after deploy - run key-dependent smoke checks (token issuance, secrets write/read, transit round-trip) - review [v0.5.1 release notes](../releases/v0.5.1.md) and @@ -238,7 +291,7 @@ If CORS is disabled or origin is not allowed, browser requests can fail even if - Symptom: tokenization endpoints return `404`/`500` after upgrading to `v0.4.x` - Likely cause: tokenization migration (`000002_add_tokenization`) not applied or partially applied - Fix: - - run `./bin/app migrate` (or Docker `... allisson/secrets:v0.5.1 migrate`) + - run `./bin/app migrate` (or Docker `... allisson/secrets:v0.6.0 migrate`) - verify migration logs indicate `000002_add_tokenization` applied for your DB - confirm initial KEK exists (`create-kek` if missing) - re-run smoke flow for tokenization (`tokenize -> detokenize -> validate -> revoke`) diff --git a/docs/metadata.json b/docs/metadata.json index 64af35f..f2bc261 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { - "current_release": "v0.5.1", + "current_release": "v0.6.0", "api_version": "v1", "last_docs_refresh": "2026-02-19" } diff --git a/docs/operations/key-management.md b/docs/operations/key-management.md index 0c2e8d8..0ba53f1 100644 --- a/docs/operations/key-management.md +++ b/docs/operations/key-management.md @@ -1,6 +1,6 @@ # 🔑 Key Management Operations -> Last updated: 2026-02-14 +> Last updated: 2026-02-19 This guide covers master keys and KEK lifecycle operations. @@ -10,14 +10,28 @@ Generate: ```bash ./bin/app create-master-key --id prod-2026-01 + +# KMS mode (recommended for production) +./bin/app create-master-key --id prod-2026-01 \ + --kms-provider=localsecrets \ + --kms-key-uri="base64key://" ``` Docker image equivalent: ```bash -docker run --rm allisson/secrets:latest create-master-key --id prod-2026-01 +docker run --rm allisson/secrets:v0.6.0 create-master-key --id prod-2026-01 +``` + +Rotate master key: + +```bash +./bin/app rotate-master-key --id prod-2026-08 ``` +`rotate-master-key` reads current `MASTER_KEYS`, appends a new key, and sets +`ACTIVE_MASTER_KEY_ID` to the new key. It does not remove old keys automatically. + Set output in environment: ```dotenv @@ -75,9 +89,33 @@ Operational step: 1. Validate backups and operational readiness 2. Rotate master key (if planned) -3. Rotate KEK -4. Verify secret read/write and transit encrypt/decrypt -5. Review audit logs for anomalies +3. Restart API instances to load the updated master key chain +4. Rotate KEK +5. Verify secret read/write and transit encrypt/decrypt +6. Remove old master key from `MASTER_KEYS` after KEK rotation completes +7. Review audit logs for anomalies + +## Copy/Paste Rotation Runbook + +Use this sequence for master key rotation with minimal operator drift: + +```bash +# 1) Generate next master key entry +./bin/app rotate-master-key --id prod-2026-08 + +# 2) Update MASTER_KEYS / ACTIVE_MASTER_KEY_ID from command output +# 3) Restart API instances (rolling) + +# 4) Rotate KEK to re-wrap with active master key +./bin/app rotate-kek --algorithm aes-gcm + +# 5) Validate key-dependent paths +curl -sS http://localhost:8080/health +curl -sS http://localhost:8080/ready + +# 6) Remove old master key from MASTER_KEYS +# 7) Restart API instances again +``` ## Transit Create/Rotate Automation @@ -104,3 +142,4 @@ create key -> 409 Conflict: rotate key - [Security model](../concepts/security-model.md) - [Transit API](../api/transit.md) - [Environment variables](../configuration/environment-variables.md) +- [KMS setup guide](kms-setup.md) diff --git a/docs/operations/kms-migration-checklist.md b/docs/operations/kms-migration-checklist.md new file mode 100644 index 0000000..4fccb80 --- /dev/null +++ b/docs/operations/kms-migration-checklist.md @@ -0,0 +1,55 @@ +# ✅ KMS Migration Checklist + +> Last updated: 2026-02-19 + +Use this checklist for migrating from legacy plaintext master keys to KMS mode. + +## 1) Precheck + +- [ ] Confirm target release is `v0.6.0` or newer +- [ ] Back up current environment configuration +- [ ] Confirm rollback owner and change window +- [ ] Confirm KMS provider credentials are available in runtime +- [ ] Confirm KMS encrypt/decrypt permissions are granted + +## 2) Build KMS key chain + +- [ ] Generate new KMS-encrypted key with `create-master-key --kms-provider ... --kms-key-uri ...` +- [ ] Re-encode existing legacy plaintext keys into KMS ciphertext +- [ ] Build `MASTER_KEYS` with only KMS ciphertext entries (no plaintext mix) +- [ ] Set `KMS_PROVIDER`, `KMS_KEY_URI`, and `ACTIVE_MASTER_KEY_ID` + +Reference: [KMS setup guide](kms-setup.md#migration-from-legacy-mode) + +## 3) Rollout + +- [ ] Restart API instances (rolling) +- [ ] Verify startup logs show KMS mode and key decrypt lines +- [ ] Run baseline checks: `GET /health`, `GET /ready` +- [ ] Run key-dependent smoke checks: token issuance, secrets, transit + +Reference: [Production rollout golden path](production-rollout.md) + +## 4) Rotation and cleanup + +- [ ] Rotate KEK after switching to KMS key chain +- [ ] Verify reads/decrypt for existing data still succeed +- [ ] Remove old key entries from `MASTER_KEYS` only after verification +- [ ] Restart API instances again after key-chain cleanup + +Reference: [Key management operations](key-management.md) + +## 5) Rollback readiness + +- [ ] Keep previous image tag available +- [ ] Keep pre-change env snapshot available +- [ ] If rollback needed, revert app version first +- [ ] Re-validate health and smoke checks after rollback + +Reference: [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md#rollback-notes) + +## See also + +- [KMS setup guide](kms-setup.md) +- [Key management operations](key-management.md) +- [Troubleshooting](../getting-started/troubleshooting.md) diff --git a/docs/operations/kms-setup.md b/docs/operations/kms-setup.md new file mode 100644 index 0000000..bef8af2 --- /dev/null +++ b/docs/operations/kms-setup.md @@ -0,0 +1,814 @@ +# KMS Setup Guide + +> Last updated: 2026-02-19 + +This guide covers setting up Key Management Service (KMS) integration for encrypting master keys at rest. KMS mode provides an additional security layer by ensuring master keys are never stored in plaintext. + +## Table of Contents + +- [Overview](#overview) +- [Quick Start (Local Development)](#quick-start-local-development) +- [Provider Setup](#provider-setup) + - [Provider Quick Matrix](#provider-quick-matrix) + - [Placeholders Legend](#placeholders-legend) + - [Ciphertext Format Caveats](#ciphertext-format-caveats) + - [Provider Preflight Validation](#provider-preflight-validation) + - [Google Cloud KMS](#google-cloud-kms) + - [AWS KMS](#aws-kms) + - [Azure Key Vault](#azure-key-vault) + - [HashiCorp Vault](#hashicorp-vault) +- [Runtime Injection Examples](#runtime-injection-examples) +- [Migration from Legacy Mode](#migration-from-legacy-mode) +- [Key Rotation](#key-rotation) +- [Troubleshooting](#troubleshooting) + +## Overview + +**KMS Mode** encrypts master keys using external Key Management Services before storing them in environment variables. This provides: + +- **Defense in Depth**: Master keys encrypted at rest, even if environment variables are compromised +- **Audit Trail**: KMS providers log all key access operations +- **Compliance**: Meets regulatory requirements for key management (e.g., PCI-DSS, HIPAA) +- **Centralized Management**: KMS keys managed separately from application secrets + +**Legacy Mode** stores master keys as plaintext base64-encoded values. This is **only suitable for development and testing**. + +### Architecture + +```text +Application Environment Variables + ↓ +MASTER_KEYS (KMS-encrypted ciphertext) + ↓ +KMS Decryption (at application startup) + ↓ +In-Memory Master Key Chain (plaintext) + ↓ +KEK Encryption/Decryption + ↓ +DEK Encryption/Decryption + ↓ +Data Encryption/Decryption +``` + +## Quick Start (Local Development) + +For local testing without cloud KMS, use the `localsecrets` provider: + +### 1. Generate a KMS Key + +The KMS key is used to encrypt/decrypt master keys. Generate a 32-byte key: + +```bash +# Generate random 32-byte key and encode as base64 +openssl rand -base64 32 +# Output: smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4= +``` + +**⚠️ Security**: Store this KMS key securely! In production, use cloud KMS instead of `localsecrets`. + +### 2. Generate an Encrypted Master Key + +```bash +./bin/app create-master-key \ + --kms-provider=localsecrets \ + --kms-key-uri="base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4=" +``` + +Output: + +```text +# KMS Mode: Encrypting master key with KMS +# KMS Provider: localsecrets + +# Master Key Configuration (KMS Mode) +KMS_PROVIDER="localsecrets" +KMS_KEY_URI="base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4=" +MASTER_KEYS="master-key-2026-02-19:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04" +ACTIVE_MASTER_KEY_ID="master-key-2026-02-19" +``` + +### 3. Configure Environment + +Add to `.env`: + +```bash +KMS_PROVIDER=localsecrets +KMS_KEY_URI=base64key://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4= +MASTER_KEYS=master-key-2026-02-19:ARiEeAASDiXKAxzOQCw2NxQfrHAc33CPP/7SsvuVjVvq1olzRBudplPoXRkquRWUXQ+CnEXi15LACqXuPGszLS+anJUrdn04 +ACTIVE_MASTER_KEY_ID=master-key-2026-02-19 +``` + +### 4. Start the Application + +```bash +./bin/app server +``` + +Check logs for successful KMS initialization: + +```text +INFO KMS mode enabled provider=localsecrets +INFO master key decrypted via KMS key_id=master-key-2026-02-19 +INFO master key chain loaded active_master_key_id=master-key-2026-02-19 total_keys=1 +``` + +## Provider Setup + +### Provider Quick Matrix + +| Provider | URI format | Required auth | Minimum permission | +| --- | --- | --- | --- | +| `localsecrets` | `base64key://` | none | local key only | +| `gcpkms` | `gcpkms://projects//locations//keyRings//cryptoKeys/` | `GOOGLE_APPLICATION_CREDENTIALS` | encrypt + decrypt | +| `awskms` | `awskms:///` | AWS SDK default chain (`AWS_ACCESS_KEY_ID`/role) | `kms:Encrypt`, `kms:Decrypt` | +| `azurekeyvault` | `azurekeyvault://.vault.azure.net/keys/` | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | key encrypt + decrypt | +| `hashivault` | `hashivault:///` | `VAULT_ADDR`, `VAULT_TOKEN` | transit encrypt + decrypt | + +### Placeholders Legend + +- ``: one of `localsecrets`, `gcpkms`, `awskms`, `azurekeyvault`, `hashivault` +- ``: provider-specific KMS URI shown in the matrix above +- ``: full `id:ciphertext` output from `create-master-key` +- ``: ciphertext produced by encrypting an existing legacy key with your KMS + +Treat placeholders as templates only; replace with exact runtime values before applying. + +### Ciphertext Format Caveats + +- `MASTER_KEYS` values in KMS mode must be ciphertext outputs from the selected provider. +- Do not assume provider outputs use the same encoding format: + - Cloud KMS tooling often returns base64-like blobs. + - Vault transit typically returns prefixed ciphertext (for example `vault:v1:...`). +- Keep each provider's ciphertext format as-is; do not transform to another format unless the + provider documentation requires it. +- Never mix plaintext legacy values and KMS ciphertext values in `MASTER_KEYS` when KMS mode is enabled. + +### Provider Preflight Validation + +Before rollout, validate credentials and permissions with a quick encrypt/decrypt round-trip. + +Use an isolated temp folder and clean it up when done: + +```bash +mkdir -p /tmp/secrets-kms-preflight +``` + +Google Cloud KMS: + +```bash +printf 'kms-preflight' > /tmp/secrets-kms-preflight/input.txt +gcloud kms encrypt --project="$PROJECT_ID" --location="us-central1" --keyring="secrets-keyring" \ + --key="master-key-encryption" --plaintext-file="/tmp/secrets-kms-preflight/input.txt" \ + --ciphertext-file="/tmp/secrets-kms-preflight/cipher.bin" +gcloud kms decrypt --project="$PROJECT_ID" --location="us-central1" --keyring="secrets-keyring" \ + --key="master-key-encryption" --ciphertext-file="/tmp/secrets-kms-preflight/cipher.bin" \ + --plaintext-file="/tmp/secrets-kms-preflight/output.txt" +cmp /tmp/secrets-kms-preflight/input.txt /tmp/secrets-kms-preflight/output.txt +``` + +AWS KMS: + +```bash +printf 'kms-preflight' > /tmp/secrets-kms-preflight/input.txt +CIPHERTEXT_B64="$(aws kms encrypt --key-id alias/secrets-master-key \ + --plaintext fileb:///tmp/secrets-kms-preflight/input.txt --query CiphertextBlob --output text)" +export CIPHERTEXT_B64 + +python3 - <<'PY' +import base64, os +data = base64.b64decode(os.environ["CIPHERTEXT_B64"]) +open('/tmp/secrets-kms-preflight/cipher.bin', 'wb').write(data) +PY + +DECRYPTED_B64="$(aws kms decrypt --ciphertext-blob fileb:///tmp/secrets-kms-preflight/cipher.bin \ + --query Plaintext --output text)" +export DECRYPTED_B64 + +python3 - <<'PY' +import base64, os +data = base64.b64decode(os.environ["DECRYPTED_B64"]) +open('/tmp/secrets-kms-preflight/output.txt', 'wb').write(data) +PY + +cmp /tmp/secrets-kms-preflight/input.txt /tmp/secrets-kms-preflight/output.txt +``` + +Azure Key Vault: + +```bash +# Credential/permission preflight +az keyvault key show --vault-name secrets-kv-unique --name master-key-encryption + +# Optional encrypt/decrypt smoke test (CLI/algorithm support may vary by key type) +az keyvault key encrypt --vault-name secrets-kv-unique --name master-key-encryption \ + --algorithm RSA-OAEP-256 --value "kms-preflight" +``` + +HashiCorp Vault Transit: + +```bash +PLAINTEXT_B64="$(printf 'kms-preflight' | base64 | tr -d '\n')" +CIPHERTEXT="$(vault write -field=ciphertext transit/encrypt/master-key-encryption plaintext="$PLAINTEXT_B64")" +vault write -field=plaintext transit/decrypt/master-key-encryption ciphertext="$CIPHERTEXT" | \ + python3 -c 'import base64,sys;print(base64.b64decode(sys.stdin.read().strip()).decode(), end="")' +``` + +Cleanup: + +```bash +rm -rf /tmp/secrets-kms-preflight +``` + +### Google Cloud KMS + +#### GCP Prerequisites + +1. **GCP Project**: Active Google Cloud project with billing enabled +2. **API Enabled**: Enable Cloud KMS API +3. **Credentials**: Service account with `cloudkms.cryptoKeyVersions.useToEncrypt` and `cloudkms.cryptoKeyVersions.useToDecrypt` permissions + +#### GCP Setup Steps + +```bash +# 1. Set project ID +export PROJECT_ID="my-gcp-project" + +# 2. Enable Cloud KMS API +gcloud services enable cloudkms.googleapis.com --project=$PROJECT_ID + +# 3. Create key ring +gcloud kms keyrings create secrets-keyring \ + --location=us-central1 \ + --project=$PROJECT_ID + +# 4. Create crypto key +gcloud kms keys create master-key-encryption \ + --location=us-central1 \ + --keyring=secrets-keyring \ + --purpose=encryption \ + --project=$PROJECT_ID + +# 5. Create service account +gcloud iam service-accounts create secrets-kms-user \ + --display-name="Secrets KMS User" \ + --project=$PROJECT_ID + +# 6. Grant permissions +gcloud kms keys add-iam-policy-binding master-key-encryption \ + --location=us-central1 \ + --keyring=secrets-keyring \ + --member="serviceAccount:secrets-kms-user@$PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/cloudkms.cryptoKeyEncrypterDecrypter" \ + --project=$PROJECT_ID + +# 7. Generate service account key +gcloud iam service-accounts keys create gcp-kms-key.json \ + --iam-account=secrets-kms-user@$PROJECT_ID.iam.gserviceaccount.com \ + --project=$PROJECT_ID +``` + +#### GCP Generate Encrypted Master Key + +```bash +# Set GCP credentials +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/gcp-kms-key.json" + +# Generate encrypted master key +./bin/app create-master-key \ + --kms-provider=gcpkms \ + --kms-key-uri="gcpkms://projects/$PROJECT_ID/locations/us-central1/keyRings/secrets-keyring/cryptoKeys/master-key-encryption" +``` + +#### GCP Environment Configuration + +```bash +# Application environment +GOOGLE_APPLICATION_CREDENTIALS=/path/to/gcp-kms-key.json +KMS_PROVIDER=gcpkms +KMS_KEY_URI=gcpkms://projects/my-gcp-project/locations/us-central1/keyRings/secrets-keyring/cryptoKeys/master-key-encryption +MASTER_KEYS= +ACTIVE_MASTER_KEY_ID= +``` + +### AWS KMS + +#### AWS Prerequisites + +1. **AWS Account**: Active AWS account with appropriate permissions +2. **IAM User/Role**: With `kms:Encrypt` and `kms:Decrypt` permissions +3. **AWS Credentials**: Configured via AWS CLI or environment variables + +#### AWS Setup Steps + +```bash +# 1. Create KMS key +aws kms create-key \ + --description "Secrets Master Key Encryption" \ + --key-usage ENCRYPT_DECRYPT \ + --origin AWS_KMS \ + --region us-east-1 + +# Output: Copy the KeyId from response +# Example: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 + +# 2. Create alias for easier reference +aws kms create-alias \ + --alias-name alias/secrets-master-key \ + --target-key-id \ + --region us-east-1 + +# 3. Grant IAM permissions +# Attach this policy to your application's IAM role/user: +cat > secrets-kms-policy.json < +ACTIVE_MASTER_KEY_ID= +``` + +### Azure Key Vault + +#### Azure Prerequisites + +1. **Azure Subscription**: Active Azure subscription +2. **Key Vault**: Azure Key Vault instance created +3. **Service Principal**: With `keys/encrypt` and `keys/decrypt` permissions + +#### Azure Setup Steps + +```bash +# 1. Create resource group +az group create \ + --name secrets-rg \ + --location eastus + +# 2. Create Key Vault +az keyvault create \ + --name secrets-kv-unique \ + --resource-group secrets-rg \ + --location eastus \ + --sku standard + +# 3. Create key +az keyvault key create \ + --vault-name secrets-kv-unique \ + --name master-key-encryption \ + --protection software \ + --size 2048 \ + --kty RSA + +# 4. Create service principal +az ad sp create-for-rbac \ + --name secrets-kms-sp \ + --role "Key Vault Crypto User" \ + --scopes /subscriptions//resourceGroups/secrets-rg/providers/Microsoft.KeyVault/vaults/secrets-kv-unique + +# Output: Save tenantId, appId, password + +# 5. Set access policy +az keyvault set-policy \ + --name secrets-kv-unique \ + --spn \ + --key-permissions encrypt decrypt +``` + +#### Azure Generate Encrypted Master Key + +```bash +# Set Azure credentials +export AZURE_TENANT_ID="your-tenant-id" +export AZURE_CLIENT_ID="your-client-id" +export AZURE_CLIENT_SECRET="your-client-secret" + +# Generate encrypted master key +./bin/app create-master-key \ + --kms-provider=azurekeyvault \ + --kms-key-uri="azurekeyvault://secrets-kv-unique.vault.azure.net/keys/master-key-encryption" +``` + +#### Azure Environment Configuration + +```bash +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret +KMS_PROVIDER=azurekeyvault +KMS_KEY_URI=azurekeyvault://secrets-kv-unique.vault.azure.net/keys/master-key-encryption +MASTER_KEYS= +ACTIVE_MASTER_KEY_ID= +``` + +### HashiCorp Vault + +#### Vault Prerequisites + +1. **Vault Server**: Running HashiCorp Vault instance +2. **Transit Engine**: Enabled transit secrets engine +3. **Token/AppRole**: Authentication credentials with `encrypt` and `decrypt` permissions + +#### Vault Setup Steps + +```bash +# 1. Enable transit secrets engine +vault secrets enable transit + +# 2. Create encryption key +vault write -f transit/keys/master-key-encryption + +# 3. Create policy +cat > secrets-kms-policy.hcl < +ACTIVE_MASTER_KEY_ID= +``` + +## Runtime Injection Examples + +Prefer secrets managers/orchestrator secrets over inline plaintext in deployment manifests. + +Docker Compose example: + +```yaml +services: + secrets-api: + image: allisson/secrets:v0.6.0 + env_file: + - .env + environment: + KMS_PROVIDER: gcpkms + KMS_KEY_URI: gcpkms://projects/my-project/locations/us-central1/keyRings/secrets/cryptoKeys/master-key + MASTER_KEYS: ${MASTER_KEYS} + ACTIVE_MASTER_KEY_ID: ${ACTIVE_MASTER_KEY_ID} +``` + +Kubernetes example: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secrets-api +spec: + template: + spec: + containers: + - name: app + image: allisson/secrets:v0.6.0 + env: + - name: KMS_PROVIDER + value: gcpkms + - name: KMS_KEY_URI + valueFrom: + secretKeyRef: + name: secrets-kms + key: kms-key-uri + - name: MASTER_KEYS + valueFrom: + secretKeyRef: + name: secrets-master-keys + key: master-keys + - name: ACTIVE_MASTER_KEY_ID + valueFrom: + secretKeyRef: + name: secrets-master-keys + key: active-master-key-id +``` + +## Migration from Legacy Mode + +To migrate from plaintext master keys to KMS mode: + +### Step 1: Set Up KMS Provider + +Follow provider-specific setup instructions above. + +### Step 2: Generate New KMS-Encrypted Master Key + +```bash +./bin/app create-master-key \ + --id=master-key-kms-2026 \ + --kms-provider= \ + --kms-key-uri= +``` + +### Step 3: Re-encode Existing Master Keys for KMS + +Do not mix plaintext and KMS-encrypted entries in `MASTER_KEYS` when KMS mode is enabled. + +Unsupported (do not use): + +```bash +MASTER_KEYS=old-plaintext-key:,new-key: +``` + +Supported KMS mode input: all entries must be KMS-encrypted ciphertext. + +```bash +# Example shape (all values are KMS-encrypted ciphertext) +MASTER_KEYS=old-key:,master-key-kms-2026: +ACTIVE_MASTER_KEY_ID=old-key +KMS_PROVIDER= +KMS_KEY_URI= +``` + +To produce ``, use your provider's native encrypt API with the +existing plaintext 32-byte key material. + +Provider examples for re-encoding an existing plaintext key: + +```bash +# Input: old plaintext key as base64 string (from legacy MASTER_KEYS value) +OLD_KEY_B64="bEu+O/9NOFAsWf1dhVB9aprmumKhhBcE6o7UPVmI43Y=" +printf '%s' "$OLD_KEY_B64" | base64 --decode > /tmp/old-master-key.bin +``` + +Google Cloud KMS: + +```bash +gcloud kms encrypt \ + --project="my-gcp-project" \ + --location="us-central1" \ + --keyring="secrets-keyring" \ + --key="master-key-encryption" \ + --plaintext-file="/tmp/old-master-key.bin" \ + --ciphertext-file="/tmp/old-master-key.cipher" + +OLD_KEY_KMS_CIPHERTEXT="$(base64 < /tmp/old-master-key.cipher | tr -d '\n')" +``` + +AWS KMS: + +```bash +OLD_KEY_KMS_CIPHERTEXT="$(aws kms encrypt \ + --key-id alias/secrets-master-key \ + --plaintext fileb:///tmp/old-master-key.bin \ + --query CiphertextBlob \ + --output text)" +``` + +Azure Key Vault: + +```bash +OLD_KEY_KMS_CIPHERTEXT="$(az keyvault key encrypt \ + --vault-name secrets-kv-unique \ + --name master-key-encryption \ + --algorithm RSA-OAEP-256 \ + --file /tmp/old-master-key.bin \ + --query result \ + --output tsv)" +``` + +HashiCorp Vault Transit: + +```bash +OLD_KEY_KMS_CIPHERTEXT="$(vault write -field=ciphertext transit/encrypt/master-key-encryption \ + plaintext="$OLD_KEY_B64")" +``` + +Then build your KMS-only chain: + +```bash +MASTER_KEYS="old-key:${OLD_KEY_KMS_CIPHERTEXT},master-key-kms-2026:" +``` + +### Step 4: Update Environment (Encrypted-Only Chain) + +Update environment with only KMS-encrypted `MASTER_KEYS` entries: + +```bash +KMS_PROVIDER= +KMS_KEY_URI= +MASTER_KEYS=old-key:,master-key-kms-2026: +ACTIVE_MASTER_KEY_ID=old-key +``` + +### Step 5: Restart Application + +Verify both keys are loaded: + +```text +INFO KMS mode enabled provider=gcpkms +INFO master key decrypted via KMS key_id=old-key +INFO master key decrypted via KMS key_id=master-key-kms-2026 +INFO master key chain loaded active_master_key_id=old-key total_keys=2 +``` + +### Step 6: Rotate KEKs to New Master Key + +```bash +# Switch active master key to KMS version +export ACTIVE_MASTER_KEY_ID=master-key-kms-2026 + +# Restart application +./bin/app server + +# Rotate all KEKs (re-encrypts with new master key) +./bin/app rotate-kek --algorithm aes-gcm +``` + +### Step 7: Remove Old Master Key + +After verifying all KEKs are encrypted with the new master key: + +```bash +# Remove old key from MASTER_KEYS +MASTER_KEYS=master-key-kms-2026: +ACTIVE_MASTER_KEY_ID=master-key-kms-2026 +``` + +## Key Rotation + +Rotate master keys regularly (recommended: every 90-180 days). + +### Generate New Master Key + +```bash +./bin/app rotate-master-key --id=master-key-2026-08 +``` + +Output includes combined configuration: + +```bash +KMS_PROVIDER=gcpkms +KMS_KEY_URI=gcpkms://... +MASTER_KEYS=old-key:,master-key-2026-08: +ACTIVE_MASTER_KEY_ID=master-key-2026-08 +``` + +### Rotation Workflow + +```bash +# 1. Update environment variables with output from rotate-master-key +# 2. Restart application +./bin/app server + +# 3. Verify both keys loaded +# Logs should show: total_keys=2 + +# 4. Rotate all KEKs to use new master key +./bin/app rotate-kek --algorithm aes-gcm + +# 5. After KEK rotation complete, remove old master key +MASTER_KEYS=master-key-2026-08: +ACTIVE_MASTER_KEY_ID=master-key-2026-08 + +# 6. Restart application +./bin/app server +``` + +## Troubleshooting + +### Issue: "KMS keeper does not support encryption" + +**Cause**: Provider driver not imported correctly. + +**Solution**: Verify provider import in code (should be automatic for supported providers). + +### Issue: "failed to open KMS keeper: authentication failed" + +**Cause**: Missing or invalid credentials for KMS provider. + +**Solution**: + +- **GCP**: Check `GOOGLE_APPLICATION_CREDENTIALS` points to valid service account key +- **AWS**: Verify `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are set +- **Azure**: Confirm `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` are correct +- **Vault**: Ensure `VAULT_ADDR` and `VAULT_TOKEN` are valid + +### Issue: "KMS_PROVIDER is set but KMS_KEY_URI is empty" + +**Cause**: Incomplete KMS configuration. + +**Solution**: Both `KMS_PROVIDER` and `KMS_KEY_URI` must be set together (or both empty for legacy mode). + +### Issue: "failed to decrypt master key via KMS" + +**Cause**: KMS key permissions insufficient or key deleted/disabled. + +**Solution**: + +- Verify IAM permissions include `decrypt` capability +- Check KMS key is enabled and not scheduled for deletion +- Confirm `KMS_KEY_URI` matches the key used during encryption + +### Issue: Startup fails with mixed plaintext and KMS master keys + +**Cause**: `MASTER_KEYS` contains a mix of plaintext base64 and KMS ciphertext entries while KMS mode is enabled. + +**Solution**: + +- Use plaintext entries only in legacy mode (both `KMS_PROVIDER` and `KMS_KEY_URI` unset) +- Use KMS ciphertext entries only in KMS mode (both KMS variables set) +- Re-encode legacy keys with provider-native encrypt APIs before enabling KMS mode + +### Issue: Application slow to start with KMS enabled + +**Cause**: KMS decryption happens at startup (network round-trip). + +**Expected Behavior**: Startup delay of 100-500ms per master key (acceptable trade-off for security). + +**Optimization**: Minimize number of master keys (typically 1-2 keys). + +### Debug Logging + +Enable debug logs to troubleshoot KMS issues: + +```bash +LOG_LEVEL=debug ./bin/app server +``` + +Look for: + +```text +DEBUG KMS keeper opened uri=gcpkms://... +DEBUG master key decrypted key_id=master-key-2026-02-19 ciphertext_length=64 +``` + +## See Also + +- [Key Management Guide](key-management.md) - KEK and DEK rotation procedures +- [Security Hardening](security-hardening.md) - Production security best practices +- [Production Deployment](production.md) - Production deployment checklist diff --git a/docs/operations/production-rollout.md b/docs/operations/production-rollout.md index 4ed96d1..328a0b8 100644 --- a/docs/operations/production-rollout.md +++ b/docs/operations/production-rollout.md @@ -6,7 +6,7 @@ Use this runbook for a standard production rollout with verification and rollbac ## Scope -- Deploy target: Secrets `v0.5.1` +- Deploy target: Secrets `v0.6.0` - Database schema changes: run migrations before traffic cutover - Crypto bootstrap: ensure initial KEK exists for write/encrypt flows @@ -23,17 +23,17 @@ Use this runbook for a standard production rollout with verification and rollbac ```bash # 1) Pull target release -docker pull allisson/secrets:v0.5.1 +docker pull allisson/secrets:v0.6.0 # 2) Run migrations -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 migrate # 3) Bootstrap KEK only for first-time environment setup -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.5.1 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.6.0 create-kek --algorithm aes-gcm # 4) Start API docker run --rm --name secrets-api --network secrets-net --env-file .env -p 8080:8080 \ - allisson/secrets:v0.5.1 server + allisson/secrets:v0.6.0 server ``` ## Verification Gates @@ -81,7 +81,8 @@ Gate C (policy and observability): ## See also - [Production deployment guide](production.md) -- [v0.5.1 release notes](../releases/v0.5.1.md) -- [v0.5.1 upgrade guide](../releases/v0.5.1-upgrade.md) +- [v0.6.0 release notes](../releases/v0.6.0.md) +- [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md) +- [KMS migration checklist](kms-migration-checklist.md) - [Release compatibility matrix](../releases/compatibility-matrix.md) - [Smoke test guide](../getting-started/smoke-test.md) diff --git a/docs/operations/production.md b/docs/operations/production.md index 6d8a98b..7ff05ec 100644 --- a/docs/operations/production.md +++ b/docs/operations/production.md @@ -36,6 +36,7 @@ Minimal reverse proxy checklist: - Inject env vars via secure runtime mechanism (orchestrator secrets, vault/KMS integrations) - Do not bake `MASTER_KEYS` into images +- Prefer KMS mode for master keys (`KMS_PROVIDER` + `KMS_KEY_URI`) in production - Use distinct clients/policies per workload - Keep token expiration short enough for your threat model @@ -164,7 +165,7 @@ Adjust retention to match your compliance and incident-response requirements. - Follow [Production rollout golden path](production-rollout.md) for step-by-step deployment, verification gates, and rollback triggers - Use [Release compatibility matrix](../releases/compatibility-matrix.md) before planning upgrades -- Keep [v0.5.1 upgrade guide](../releases/v0.5.1-upgrade.md) attached to rollout change tickets +- Keep [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md) attached to rollout change tickets ## See also @@ -175,8 +176,9 @@ Adjust retention to match your compliance and incident-response requirements. - [Monitoring](monitoring.md) - [Operator drills (quarterly)](operator-drills.md) - [Policy smoke tests](policy-smoke-tests.md) -- [v0.5.1 release notes](../releases/v0.5.1.md) -- [v0.5.1 upgrade guide](../releases/v0.5.1-upgrade.md) +- [v0.6.0 release notes](../releases/v0.6.0.md) +- [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md) +- [KMS migration checklist](kms-migration-checklist.md) - [Release compatibility matrix](../releases/compatibility-matrix.md) - [Environment variables](../configuration/environment-variables.md) - [Security model](../concepts/security-model.md) diff --git a/docs/operations/runbook-index.md b/docs/operations/runbook-index.md index e91b054..0778fdd 100644 --- a/docs/operations/runbook-index.md +++ b/docs/operations/runbook-index.md @@ -6,11 +6,13 @@ Use this page as the single entry point for rollout, validation, and incident ru ## Release and Rollout -- [v0.5.1 release notes](../releases/v0.5.1.md) -- [v0.5.1 upgrade guide](../releases/v0.5.1-upgrade.md) +- [v0.6.0 release notes](../releases/v0.6.0.md) +- [v0.6.0 upgrade guide](../releases/v0.6.0-upgrade.md) - [Release compatibility matrix](../releases/compatibility-matrix.md) - [Production rollout golden path](production-rollout.md) - [Production deployment guide](production.md) +- [KMS setup guide](kms-setup.md) +- [KMS migration checklist](kms-migration-checklist.md) ## Authorization Policy Validation diff --git a/docs/releases/compatibility-matrix.md b/docs/releases/compatibility-matrix.md index 2c7f05c..2b91285 100644 --- a/docs/releases/compatibility-matrix.md +++ b/docs/releases/compatibility-matrix.md @@ -8,6 +8,7 @@ Use this page to understand upgrade impact between recent releases. | From -> To | Schema migration impact | Runtime/default changes | Required operator action | | --- | --- | --- | --- | +| `v0.5.1 -> v0.6.0` | No new mandatory migration | Added KMS-based master key support (`KMS_PROVIDER`, `KMS_KEY_URI`), new `rotate-master-key` CLI workflow | Decide KMS vs legacy mode, validate startup key loading, run key-dependent smoke checks | | `v0.5.0 -> v0.5.1` | No new mandatory migration | Master key memory handling bugfix and teardown zeroing hardening | Deploy `v0.5.1` and verify key-dependent flows (token, secrets, transit) | | `v0.4.x -> v0.5.1` | No new destructive schema migration required for core features | Token TTL default `24h -> 4h`; rate limiting enabled by default; CORS config introduced (disabled by default); includes `v0.5.1` master key memory handling hardening | Set explicit `AUTH_TOKEN_EXPIRATION_SECONDS`, review `RATE_LIMIT_*`, configure `CORS_*` only if browser access is required, then run key-dependent smoke checks | | `v0.4.0 -> v0.4.1` | No new mandatory migration beyond v0.4.0 baseline | Policy matcher bugfix and docs alignment | Update image tag and validate policy wildcard behavior | @@ -15,6 +16,13 @@ Use this page to understand upgrade impact between recent releases. ## Upgrade verification by target +For `v0.6.0`: + +1. `GET /health` and `GET /ready` pass +2. Startup logs confirm intended key mode (KMS or legacy) +3. `POST /v1/token` issues tokens successfully +4. Secrets and transit round-trip flows succeed + For `v0.5.1`: 1. `GET /health` and `GET /ready` pass @@ -36,6 +44,8 @@ For `v0.5.0`: ## See also +- [v0.6.0 release notes](v0.6.0.md) +- [v0.6.0 upgrade guide](v0.6.0-upgrade.md) - [v0.5.1 release notes](v0.5.1.md) - [v0.5.1 upgrade guide](v0.5.1-upgrade.md) - [v0.5.0 release notes](v0.5.0.md) diff --git a/docs/releases/v0.6.0-upgrade.md b/docs/releases/v0.6.0-upgrade.md new file mode 100644 index 0000000..23c7d35 --- /dev/null +++ b/docs/releases/v0.6.0-upgrade.md @@ -0,0 +1,96 @@ +# ⬆️ Upgrade Guide: v0.5.1 -> v0.6.0 + +> Release date: 2026-02-19 + +Use this guide to safely upgrade from `v0.5.1` to `v0.6.0`. + +## Scope + +- Release type: minor (`v0.6.0`) +- API compatibility: no `v1` endpoint contract break +- Database migration: no new mandatory migration for this release + +## What Changed + +- Added KMS-backed master key loading mode (`KMS_PROVIDER`, `KMS_KEY_URI`) +- Added KMS flags to `create-master-key` +- Added `rotate-master-key` CLI command for staged master key rotation +- Added fail-fast validation for partial KMS configuration + +## Recommended Upgrade Steps + +1. Update image/binary to `v0.6.0` +2. Decide runtime key mode: + - Keep legacy mode (no KMS vars set), or + - Enable KMS mode (`KMS_PROVIDER` and `KMS_KEY_URI` both set) +3. Restart API instances with standard rolling rollout process +4. Run baseline checks: + - `GET /health` + - `GET /ready` +5. Run key-dependent smoke checks: + - `POST /v1/token` + - Secrets write/read + - Transit encrypt/decrypt round-trip + +## Decision Path + +- Stay on legacy mode now: + - Keep `KMS_PROVIDER` and `KMS_KEY_URI` unset + - Upgrade binaries/images and validate normal crypto flows +- Adopt KMS mode now: + - Set both `KMS_PROVIDER` and `KMS_KEY_URI` + - Ensure all `MASTER_KEYS` entries are KMS ciphertext + - Follow migration workflow in [KMS setup guide](../operations/kms-setup.md) + - Track rollout gates in [KMS migration checklist](../operations/kms-migration-checklist.md) + +## Quick Verification Commands + +```bash +curl -sS http://localhost:8080/health +curl -sS http://localhost:8080/ready + +TOKEN_RESPONSE="$(curl -sS -X POST http://localhost:8080/v1/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"","client_secret":""}')" + +CLIENT_TOKEN="$(printf '%s' "${TOKEN_RESPONSE}" | jq -r '.token')" + +curl -sS -X POST http://localhost:8080/v1/secrets/upgrade/v060 \ + -H "Authorization: Bearer ${CLIENT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"value":"djA2MC1zbW9rZQ=="}' + +curl -sS -X GET http://localhost:8080/v1/secrets/upgrade/v060 \ + -H "Authorization: Bearer ${CLIENT_TOKEN}" +``` + +## Optional: Adopt KMS Mode During Upgrade + +If you are migrating from legacy plaintext master keys to KMS mode, use: + +1. [KMS setup guide](../operations/kms-setup.md) provider prerequisites +2. `create-master-key --kms-provider ... --kms-key-uri ...` +3. Staged dual-key migration and KEK rotation workflow from the KMS guide + +## Rollback Notes + +- If rollback is required, revert API instances to the previous stable image +- Revert only app version first; avoid destructive key/data rollback actions without a validated plan +- Re-run health and smoke checks after rollback + +### Rollback matrix + +| Upgrade path | First rollback action | Configuration rollback | Validation | +| --- | --- | --- | --- | +| Legacy mode (`KMS_*` unset) | Roll app image/binary back to previous stable version | Keep existing `MASTER_KEYS` and `ACTIVE_MASTER_KEY_ID` | `GET /health`, `GET /ready`, token + secrets/transit smoke checks | +| KMS mode (`KMS_*` set) | Roll app image/binary back to previous stable version | Keep KMS variables and KMS ciphertext `MASTER_KEYS` unchanged first; do not mix plaintext and KMS entries | Verify startup decrypt logs, then run token + secrets/transit smoke checks | + +Use [KMS migration checklist](../operations/kms-migration-checklist.md) to document rollback readiness before cutover. + +## See also + +- [v0.6.0 release notes](v0.6.0.md) +- [Release compatibility matrix](compatibility-matrix.md) +- [KMS setup guide](../operations/kms-setup.md) +- [KMS migration checklist](../operations/kms-migration-checklist.md) +- [Production rollout golden path](../operations/production-rollout.md) diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md new file mode 100644 index 0000000..0a790ba --- /dev/null +++ b/docs/releases/v0.6.0.md @@ -0,0 +1,58 @@ +# 🚀 Secrets v0.6.0 Release Notes + +> Release date: 2026-02-19 + +This minor release introduces KMS-backed master key support for encrypting key material at rest, +adds a dedicated master key rotation command, and expands operational documentation for provider setup +and migration workflows. + +## Highlights + +- Added KMS support for master key loading and decryption at startup +- Added CLI KMS flags to `create-master-key` (`--kms-provider`, `--kms-key-uri`) +- Added new `rotate-master-key` CLI command for staged master key rotation +- Added provider setup and migration runbook: [KMS setup guide](../operations/kms-setup.md) + +## Runtime Changes + +- New environment variables: + - `KMS_PROVIDER` + - `KMS_KEY_URI` +- Master key loading now supports two modes: + - KMS mode: both variables set + - Legacy mode: both variables unset +- Startup fails fast if only one KMS variable is set + +## Security and Operations Impact + +- KMS mode encrypts master keys at rest and centralizes key access control in your KMS provider +- Existing legacy environments remain supported without immediate migration +- Master key rotation now has an explicit CLI workflow for appending a new active key before cleanup + +## Upgrade Notes + +1. Deploy binaries/images with `v0.6.0` +2. Keep legacy mode or configure KMS mode explicitly (`KMS_PROVIDER` + `KMS_KEY_URI`) +3. Run standard health checks and key-dependent smoke checks +4. If adopting KMS mode, follow the staged migration in [KMS setup guide](../operations/kms-setup.md) + +## Operator Verification Checklist + +1. Confirm `GET /health` and `GET /ready` succeed +2. Confirm startup logs reflect intended key mode and active master key +3. Confirm token issuance and secrets/transit round-trip flows +4. Confirm no KMS auth/decrypt errors in startup logs + +## Documentation Updates + +- Added [v0.6.0 upgrade guide](v0.6.0-upgrade.md) +- Added [KMS setup guide](../operations/kms-setup.md) +- Updated [CLI commands](../cli/commands.md) with KMS flags and `rotate-master-key` +- Updated [Environment variables](../configuration/environment-variables.md) with KMS mode configuration + +## See also + +- [v0.6.0 upgrade guide](v0.6.0-upgrade.md) +- [Release compatibility matrix](compatibility-matrix.md) +- [KMS setup guide](../operations/kms-setup.md) +- [Key management operations](../operations/key-management.md) diff --git a/go.mod b/go.mod index e6d0ac5..9cb68b0 100644 --- a/go.mod +++ b/go.mod @@ -21,21 +21,53 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.62.0 go.opentelemetry.io/otel/metric v1.40.0 go.opentelemetry.io/otel/sdk/metric v1.40.0 + gocloud.dev v0.44.0 + gocloud.dev/secrets/hashivault v0.44.0 golang.org/x/crypto v0.48.0 golang.org/x/time v0.14.0 ) require ( + cloud.google.com/go v0.121.6 // indirect + cloud.google.com/go/auth v0.16.4 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/kms v1.22.0 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -43,14 +75,33 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/wire v0.7.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.20.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -58,10 +109,13 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/mock v0.5.0 // indirect @@ -69,10 +123,17 @@ require ( golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.247.0 // indirect + google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c2dc7c6..81f6f32 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,37 @@ +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= +cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= +cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/allisson/go-env v0.6.0 h1:YaWmnOjhF+0c7GjgJef4LC0XymV12EIoVxJHpHGnGnU= @@ -10,6 +40,34 @@ github.com/allisson/go-pwdhash v0.3.1 h1:UzR/0V77E6l63fV6EuAUj0nj1S2jdGADzgoO7UB github.com/allisson/go-pwdhash v0.3.1/go.mod h1:qMlMlCyJ2zwSV8Df406IKgY4VC/39FpiaLamOmZezYU= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= +github.com/aws/aws-sdk-go-v2/service/kms v1.41.2/go.mod h1:Pqd9k4TuespkireN206cK2QBsaBTL6X+VPAez5Qcijk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= @@ -18,6 +76,8 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0= github.com/ccoveille/go-safecast/v2 v2.0.0/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -30,6 +90,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -40,6 +102,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= @@ -52,6 +116,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -67,25 +133,70 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= +github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= +github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= +github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.20.0 h1:KQMHElgudOsr+IbJgmbjHnCTxEpKs9LnozA1D3nozU4= +github.com/hashicorp/vault/api v1.20.0/go.mod h1:GZ4pcjfzoOWpkJ3ijHNpEoAxKEsBJnVljyTe3jM2Sms= github.com/jellydator/validation v1.2.0 h1:z3P3Hk5kdT9epXDraWAfMZtOIUM7UQ0PkNAnFEUjcAk= github.com/jellydator/validation v1.2.0/go.mod h1:AaCjfkQ4Ykdcb+YCwqCtaI3wDsf2UAGhJ06lJs0VgOw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -100,8 +211,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -121,6 +238,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -140,8 +259,12 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= +github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -162,8 +285,10 @@ github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= @@ -182,6 +307,10 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +gocloud.dev v0.44.0 h1:iVyMAqFl2r6xUy7M4mfqwlN+21UpJoEtgHEcfiLMUXs= +gocloud.dev v0.44.0/go.mod h1:ZmjROXGdC/eKZLF1N+RujDlFRx3D+4Av2thREKDMVxY= +gocloud.dev/secrets/hashivault v0.44.0 h1:Zwd+EdSQ30BIaGS1w5aRTQUZDNCL133ollkns2RIzFo= +gocloud.dev/secrets/hashivault v0.44.0/go.mod h1:GRdIFK5paMZbXftw36rMJQ95CtZ/rEn/+G0G15XTMXU= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= @@ -190,8 +319,11 @@ golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -201,6 +333,18 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= +google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= +google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/di.go b/internal/app/di.go index 3a56c81..7ceaf98 100644 --- a/internal/app/di.go +++ b/internal/app/di.go @@ -52,6 +52,7 @@ type Container struct { // Services aeadManager cryptoService.AEADManager keyManager cryptoService.KeyManager + kmsService cryptoService.KMSService secretService authService.SecretService tokenService authService.TokenService @@ -101,6 +102,7 @@ type Container struct { businessMetricsInit sync.Once aeadManagerInit sync.Once keyManagerInit sync.Once + kmsServiceInit sync.Once secretServiceInit sync.Once tokenServiceInit sync.Once kekRepositoryInit sync.Once @@ -261,6 +263,14 @@ func (c *Container) KeyManager() cryptoService.KeyManager { return c.keyManager } +// KMSService returns the KMS service. +func (c *Container) KMSService() cryptoService.KMSService { + c.kmsServiceInit.Do(func() { + c.kmsService = c.initKMSService() + }) + return c.kmsService +} + // KekRepository returns the KEK repository. func (c *Container) KekRepository() (cryptoUseCase.KekRepository, error) { var err error @@ -646,7 +656,17 @@ func (c *Container) initDB() (*sql.DB, error) { // initMasterKeyChain loads the master key chain from environment variables. func (c *Container) initMasterKeyChain() (*cryptoDomain.MasterKeyChain, error) { - masterKeyChain, err := cryptoDomain.LoadMasterKeyChainFromEnv() + // Get KMS service and logger + kmsService := c.KMSService() + logger := c.Logger() + + // Load master key chain with KMS support and fail-fast validation + masterKeyChain, err := cryptoDomain.LoadMasterKeyChain( + context.Background(), + c.config, + kmsService, + logger, + ) if err != nil { return nil, fmt.Errorf("failed to load master key chain: %w", err) } @@ -797,6 +817,11 @@ func (c *Container) initKeyManager() cryptoService.KeyManager { return cryptoService.NewKeyManager(aeadManager) } +// initKMSService creates the KMS service for encrypting/decrypting master keys. +func (c *Container) initKMSService() cryptoService.KMSService { + return cryptoService.NewKMSService() +} + // initKekRepository creates the KEK repository based on the database driver. func (c *Container) initKekRepository() (cryptoUseCase.KekRepository, error) { db, err := c.DB() diff --git a/internal/config/config.go b/internal/config/config.go index 15d944d..e8a7be3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,10 @@ type Config struct { // Metrics MetricsEnabled bool MetricsNamespace string + + // KMS configuration + KMSProvider string + KMSKeyURI string } // Load loads configuration from environment variables and .env file. @@ -81,6 +85,10 @@ func Load() *Config { // Metrics MetricsEnabled: env.GetBool("METRICS_ENABLED", true), MetricsNamespace: env.GetString("METRICS_NAMESPACE", "secrets"), + + // KMS configuration + KMSProvider: env.GetString("KMS_PROVIDER", ""), + KMSKeyURI: env.GetString("KMS_KEY_URI", ""), } } diff --git a/internal/crypto/domain/errors.go b/internal/crypto/domain/errors.go index 932cb25..622cd8b 100644 --- a/internal/crypto/domain/errors.go +++ b/internal/crypto/domain/errors.go @@ -40,4 +40,22 @@ var ( // ErrKekNotFound indicates a KEK with the specified ID was not found. ErrKekNotFound = errors.Wrap(errors.ErrNotFound, "kek not found") + + // ErrKMSProviderNotSet indicates the KMS_PROVIDER environment variable is set but KMS_KEY_URI is not. + ErrKMSProviderNotSet = errors.Wrap( + errors.ErrInvalidInput, + "KMS_PROVIDER is set but KMS_KEY_URI is not configured", + ) + + // ErrKMSKeyURINotSet indicates the KMS_KEY_URI environment variable is set but KMS_PROVIDER is not. + ErrKMSKeyURINotSet = errors.Wrap( + errors.ErrInvalidInput, + "KMS_KEY_URI is set but KMS_PROVIDER is not configured", + ) + + // ErrKMSDecryptionFailed indicates KMS decryption of master keys failed. + ErrKMSDecryptionFailed = errors.Wrap(errors.ErrInvalidInput, "KMS decryption failed") + + // ErrKMSOpenKeeperFailed indicates opening KMS keeper failed. + ErrKMSOpenKeeperFailed = errors.Wrap(errors.ErrInvalidInput, "failed to open KMS keeper") ) diff --git a/internal/crypto/domain/master_key.go b/internal/crypto/domain/master_key.go index e2360ae..79e8b6e 100644 --- a/internal/crypto/domain/master_key.go +++ b/internal/crypto/domain/master_key.go @@ -1,11 +1,15 @@ package domain import ( + "context" "encoding/base64" "fmt" + "log/slog" "os" "strings" "sync" + + "github.com/allisson/secrets/internal/config" ) // MasterKey represents a cryptographic master key used to encrypt KEKs. @@ -106,3 +110,206 @@ func LoadMasterKeyChainFromEnv() (*MasterKeyChain, error) { return mkc, nil } + +// KMSService defines the interface for KMS operations required by LoadMasterKeyChain. +// This interface is implemented by crypto/service.KMSService. +type KMSService interface { + // OpenKeeper opens a secrets.Keeper for the configured KMS provider. + OpenKeeper(ctx context.Context, keyURI string) (KMSKeeper, error) +} + +// KMSKeeper defines the interface for KMS decrypt operations. +type KMSKeeper interface { + // Decrypt decrypts ciphertext using the KMS key. + Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) + + // Close releases resources held by the keeper. + Close() error +} + +// maskKeyURI masks sensitive components of a KMS key URI for secure logging. +// Examples: gcpkms://projects/***/.../cryptoKeys/*** or base64key://*** +func maskKeyURI(uri string) string { + if uri == "" { + return "" + } + + // Extract scheme + parts := strings.SplitN(uri, "://", 2) + if len(parts) != 2 { + return "***" + } + + scheme := parts[0] + remainder := parts[1] + + // For base64key, mask everything after scheme + if scheme == "base64key" { + return scheme + "://***" + } + + // For cloud providers, mask key identifiers but keep structure + // gcpkms://projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY + // awskms://KEY_ID?region=REGION + // azurekeyvault://VAULT.vault.azure.net/keys/KEY + // hashivault://KEY_NAME + + switch scheme { + case "gcpkms": + // Mask project, keyRing, and cryptoKey names + pathParts := strings.Split(remainder, "/") + for i := range pathParts { + if i%2 == 1 { // Values (odd indices) + pathParts[i] = "***" + } + } + return scheme + "://" + strings.Join(pathParts, "/") + case "awskms": + // Mask key ID but keep region parameter + queryParts := strings.SplitN(remainder, "?", 2) + masked := scheme + "://***" + if len(queryParts) == 2 { + masked += "?" + queryParts[1] + } + return masked + case "azurekeyvault", "hashivault": + // Mask the entire path + return scheme + "://***" + default: + return scheme + "://***" + } +} + +// loadMasterKeyChainFromKMS loads and decrypts master keys from MASTER_KEYS using KMS. +// The MASTER_KEYS environment variable contains KMS-encrypted keys in format "id:base64ciphertext". +// Returns ErrKMSOpenKeeperFailed, ErrKMSDecryptionFailed, ErrInvalidKeySize, or ErrActiveMasterKeyNotFound on failure. +func loadMasterKeyChainFromKMS( + ctx context.Context, + cfg *config.Config, + kmsService KMSService, + logger *slog.Logger, +) (*MasterKeyChain, error) { + raw := os.Getenv("MASTER_KEYS") + if raw == "" { + return nil, ErrMasterKeysNotSet + } + + active := os.Getenv("ACTIVE_MASTER_KEY_ID") + if active == "" { + return nil, ErrActiveMasterKeyIDNotSet + } + + // Open KMS keeper + maskedURI := maskKeyURI(cfg.KMSKeyURI) + logger.Info("opening KMS keeper", + slog.String("kms_provider", cfg.KMSProvider), + slog.String("kms_key_uri", maskedURI), + ) + + keeper, err := kmsService.OpenKeeper(ctx, cfg.KMSKeyURI) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrKMSOpenKeeperFailed, err) + } + defer func() { + if closeErr := keeper.Close(); closeErr != nil { + logger.Error("failed to close KMS keeper", slog.Any("error", closeErr)) + } + }() + + logger.Info("KMS keeper opened successfully", slog.String("kms_provider", cfg.KMSProvider)) + + mkc := &MasterKeyChain{activeID: active} + + parts := strings.SplitSeq(raw, ",") + for part := range parts { + p := strings.SplitN(strings.TrimSpace(part), ":", 2) + if len(p) != 2 { + mkc.Close() + return nil, fmt.Errorf("%w: %q", ErrInvalidMasterKeysFormat, part) + } + id := p[0] + + // Decode base64 ciphertext + ciphertext, err := base64.StdEncoding.DecodeString(p[1]) + if err != nil { + mkc.Close() + return nil, fmt.Errorf("%w for %s: %v", ErrInvalidMasterKeyBase64, id, err) + } + + logger.Info("decrypting master key with KMS", + slog.String("master_key_id", id), + slog.String("kms_provider", cfg.KMSProvider), + ) + + // Decrypt with KMS + key, err := keeper.Decrypt(ctx, ciphertext) + Zero(ciphertext) // Zero ciphertext after use + if err != nil { + mkc.Close() + return nil, fmt.Errorf("%w for master key %s: %v", ErrKMSDecryptionFailed, id, err) + } + + // Validate key size + if len(key) != 32 { + Zero(key) + mkc.Close() + return nil, fmt.Errorf( + "%w: master key %s must be 32 bytes, got %d", + ErrInvalidKeySize, + id, + len(key), + ) + } + + logger.Info("master key decrypted successfully", + slog.String("master_key_id", id), + slog.Int("key_size_bytes", len(key)), + ) + + // Make a copy of the key data before storing to prevent issues if the underlying + // slice is reused. The original 'key' slice ownership is transferred to the keychain. + mkc.keys.Store(id, &MasterKey{ID: id, Key: key}) + } + + if _, ok := mkc.Get(active); !ok { + mkc.Close() + return nil, fmt.Errorf("%w: ACTIVE_MASTER_KEY_ID=%s", ErrActiveMasterKeyNotFound, active) + } + + logger.Info("master key chain loaded successfully from KMS", + slog.String("active_master_key_id", active), + slog.String("kms_provider", cfg.KMSProvider), + ) + + return mkc, nil +} + +// LoadMasterKeyChain loads master keys from environment variables with auto-detection for KMS or legacy mode. +// If KMS_PROVIDER is set, decrypts keys using KMS. Otherwise, uses plaintext base64-encoded keys. +// Validates that both KMS_PROVIDER and KMS_KEY_URI are set together or both empty. +// Returns ErrKMSProviderNotSet, ErrKMSKeyURINotSet, or errors from loadMasterKeyChainFromKMS/LoadMasterKeyChainFromEnv. +func LoadMasterKeyChain( + ctx context.Context, + cfg *config.Config, + kmsService KMSService, + logger *slog.Logger, +) (*MasterKeyChain, error) { + // Validate KMS configuration consistency + if cfg.KMSProvider != "" && cfg.KMSKeyURI == "" { + return nil, ErrKMSProviderNotSet + } + if cfg.KMSKeyURI != "" && cfg.KMSProvider == "" { + return nil, ErrKMSKeyURINotSet + } + + // Auto-detect mode based on KMS_PROVIDER + if cfg.KMSProvider != "" { + logger.Info("loading master key chain in KMS mode", + slog.String("kms_provider", cfg.KMSProvider), + ) + return loadMasterKeyChainFromKMS(ctx, cfg, kmsService, logger) + } + + logger.Info("loading master key chain in legacy mode (plaintext)") + return LoadMasterKeyChainFromEnv() +} diff --git a/internal/crypto/domain/master_key_test.go b/internal/crypto/domain/master_key_test.go index 5ddc7b9..56da555 100644 --- a/internal/crypto/domain/master_key_test.go +++ b/internal/crypto/domain/master_key_test.go @@ -1,12 +1,16 @@ package domain import ( + "context" "encoding/base64" + "log/slog" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/allisson/secrets/internal/config" ) func TestMasterKeyChain_ActiveMasterKeyID(t *testing.T) { @@ -344,3 +348,388 @@ func TestLoadMasterKeyChainFromEnv_CloseOnError(t *testing.T) { }) } } + +// Mock implementations for KMS testing + +type mockKMSKeeper struct { + decryptFunc func(ctx context.Context, ciphertext []byte) ([]byte, error) + closeFunc func() error +} + +func (m *mockKMSKeeper) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { + if m.decryptFunc != nil { + return m.decryptFunc(ctx, ciphertext) + } + return nil, assert.AnError +} + +func (m *mockKMSKeeper) Close() error { + if m.closeFunc != nil { + return m.closeFunc() + } + return nil +} + +type mockKMSService struct { + openKeeperFunc func(ctx context.Context, keyURI string) (KMSKeeper, error) +} + +func (m *mockKMSService) OpenKeeper(ctx context.Context, keyURI string) (KMSKeeper, error) { + if m.openKeeperFunc != nil { + return m.openKeeperFunc(ctx, keyURI) + } + return nil, assert.AnError +} + +func TestMaskKeyURI(t *testing.T) { + tests := []struct { + name string + uri string + expected string + }{ + { + name: "empty URI", + uri: "", + expected: "", + }, + { + name: "base64key with key", + uri: "base64key://c29tZS1zZWNyZXQta2V5LWRhdGE=", + expected: "base64key://***", + }, + { + name: "base64key without key", + uri: "base64key://", + expected: "base64key://***", + }, + { + name: "gcpkms full URI", + uri: "gcpkms://projects/my-project/locations/us-central1/keyRings/my-ring/cryptoKeys/my-key", + expected: "gcpkms://projects/***/locations/***/keyRings/***/cryptoKeys/***", + }, + { + name: "awskms with region", + uri: "awskms://arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012?region=us-east-1", + expected: "awskms://***?region=us-east-1", + }, + { + name: "awskms without region", + uri: "awskms://alias/my-key", + expected: "awskms://***", + }, + { + name: "azurekeyvault", + uri: "azurekeyvault://my-vault.vault.azure.net/keys/my-key", + expected: "azurekeyvault://***", + }, + { + name: "hashivault", + uri: "hashivault://my-key-name", + expected: "hashivault://***", + }, + { + name: "invalid URI without scheme", + uri: "just-a-string", + expected: "***", + }, + { + name: "unknown scheme", + uri: "unknown://some-path/to/key", + expected: "unknown://***", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maskKeyURI(tt.uri) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestLoadMasterKeyChain_ValidationErrors(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + tests := []struct { + name string + kmsProvider string + kmsKeyURI string + wantErr error + errMsg string + }{ + { + name: "KMS_PROVIDER set but KMS_KEY_URI empty", + kmsProvider: "gcpkms", + kmsKeyURI: "", + wantErr: ErrKMSProviderNotSet, + errMsg: "KMS_PROVIDER is set but KMS_KEY_URI is not configured", + }, + { + name: "KMS_KEY_URI set but KMS_PROVIDER empty", + kmsProvider: "", + kmsKeyURI: "gcpkms://projects/test/locations/us/keyRings/test/cryptoKeys/test", + wantErr: ErrKMSKeyURINotSet, + errMsg: "KMS_KEY_URI is set but KMS_PROVIDER is not configured", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + KMSProvider: tt.kmsProvider, + KMSKeyURI: tt.kmsKeyURI, + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, nil, logger) + assert.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + assert.Contains(t, err.Error(), tt.errMsg) + assert.Nil(t, mkc) + }) + } +} + +func TestLoadMasterKeyChain_LegacyMode(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + key1Data := []byte("12345678901234567890123456789012") + key1 := base64.StdEncoding.EncodeToString(key1Data) + + require.NoError(t, os.Setenv("MASTER_KEYS", "key1:"+key1)) + require.NoError(t, os.Setenv("ACTIVE_MASTER_KEY_ID", "key1")) + defer func() { require.NoError(t, os.Unsetenv("MASTER_KEYS")) }() + defer func() { require.NoError(t, os.Unsetenv("ACTIVE_MASTER_KEY_ID")) }() + + cfg := &config.Config{ + KMSProvider: "", + KMSKeyURI: "", + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, nil, logger) + assert.NoError(t, err) + assert.NotNil(t, mkc) + defer mkc.Close() + + assert.Equal(t, "key1", mkc.ActiveMasterKeyID()) + mk, found := mkc.Get("key1") + assert.True(t, found) + assert.Equal(t, key1Data, mk.Key) +} + +func TestLoadMasterKeyChain_KMSMode_Success(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + // Original plaintext master key + key1Data := []byte("12345678901234567890123456789012") + + // Simulate KMS encryption by just base64 encoding the plaintext + // (in real KMS, this would be actual ciphertext) + ciphertext1 := []byte("encrypted-" + string(key1Data)) + ciphertext1Base64 := base64.StdEncoding.EncodeToString(ciphertext1) + + require.NoError(t, os.Setenv("MASTER_KEYS", "key1:"+ciphertext1Base64)) + require.NoError(t, os.Setenv("ACTIVE_MASTER_KEY_ID", "key1")) + defer func() { require.NoError(t, os.Unsetenv("MASTER_KEYS")) }() + defer func() { require.NoError(t, os.Unsetenv("ACTIVE_MASTER_KEY_ID")) }() + + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: "base64key://test", + } + + // Mock KMS service that decrypts by stripping "encrypted-" prefix + mockKeeper := &mockKMSKeeper{ + decryptFunc: func(ctx context.Context, ciphertext []byte) ([]byte, error) { + // Strip "encrypted-" prefix to get plaintext + if len(ciphertext) > 10 && string(ciphertext[:10]) == "encrypted-" { + // Return a copy to prevent issues when ciphertext is zeroed + plaintext := make([]byte, len(ciphertext)-10) + copy(plaintext, ciphertext[10:]) + return plaintext, nil + } + return nil, assert.AnError + }, + closeFunc: func() error { return nil }, + } + + mockKMS := &mockKMSService{ + openKeeperFunc: func(ctx context.Context, keyURI string) (KMSKeeper, error) { + return mockKeeper, nil + }, + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, mockKMS, logger) + assert.NoError(t, err) + assert.NotNil(t, mkc) + defer mkc.Close() + + assert.Equal(t, "key1", mkc.ActiveMasterKeyID()) + mk, found := mkc.Get("key1") + assert.True(t, found) + assert.Equal(t, key1Data, mk.Key) +} + +func TestLoadMasterKeyChain_KMSMode_MultipleKeys(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + key1Data := []byte("12345678901234567890123456789012") + key2Data := []byte("98765432109876543210987654321098") + + ciphertext1 := []byte("encrypted-" + string(key1Data)) + ciphertext2 := []byte("encrypted-" + string(key2Data)) + + ciphertext1Base64 := base64.StdEncoding.EncodeToString(ciphertext1) + ciphertext2Base64 := base64.StdEncoding.EncodeToString(ciphertext2) + + masterKeys := "key1:" + ciphertext1Base64 + ",key2:" + ciphertext2Base64 + require.NoError(t, os.Setenv("MASTER_KEYS", masterKeys)) + require.NoError(t, os.Setenv("ACTIVE_MASTER_KEY_ID", "key2")) + defer func() { require.NoError(t, os.Unsetenv("MASTER_KEYS")) }() + defer func() { require.NoError(t, os.Unsetenv("ACTIVE_MASTER_KEY_ID")) }() + + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: "base64key://test", + } + + mockKeeper := &mockKMSKeeper{ + decryptFunc: func(ctx context.Context, ciphertext []byte) ([]byte, error) { + if len(ciphertext) > 10 && string(ciphertext[:10]) == "encrypted-" { + // Return a copy to prevent issues when ciphertext is zeroed + plaintext := make([]byte, len(ciphertext)-10) + copy(plaintext, ciphertext[10:]) + return plaintext, nil + } + return nil, assert.AnError + }, + closeFunc: func() error { return nil }, + } + + mockKMS := &mockKMSService{ + openKeeperFunc: func(ctx context.Context, keyURI string) (KMSKeeper, error) { + return mockKeeper, nil + }, + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, mockKMS, logger) + assert.NoError(t, err) + assert.NotNil(t, mkc) + defer mkc.Close() + + assert.Equal(t, "key2", mkc.ActiveMasterKeyID()) + + mk1, found := mkc.Get("key1") + assert.True(t, found) + assert.Equal(t, key1Data, mk1.Key) + + mk2, found := mkc.Get("key2") + assert.True(t, found) + assert.Equal(t, key2Data, mk2.Key) +} + +func TestLoadMasterKeyChain_KMSMode_OpenKeeperError(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + require.NoError(t, os.Setenv("MASTER_KEYS", "key1:dGVzdA==")) + require.NoError(t, os.Setenv("ACTIVE_MASTER_KEY_ID", "key1")) + defer func() { require.NoError(t, os.Unsetenv("MASTER_KEYS")) }() + defer func() { require.NoError(t, os.Unsetenv("ACTIVE_MASTER_KEY_ID")) }() + + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: "invalid://uri", + } + + mockKMS := &mockKMSService{ + openKeeperFunc: func(ctx context.Context, keyURI string) (KMSKeeper, error) { + return nil, assert.AnError + }, + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, mockKMS, logger) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrKMSOpenKeeperFailed) + assert.Nil(t, mkc) +} + +func TestLoadMasterKeyChain_KMSMode_DecryptError(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + ciphertext1Base64 := base64.StdEncoding.EncodeToString([]byte("invalid-ciphertext")) + + require.NoError(t, os.Setenv("MASTER_KEYS", "key1:"+ciphertext1Base64)) + require.NoError(t, os.Setenv("ACTIVE_MASTER_KEY_ID", "key1")) + defer func() { require.NoError(t, os.Unsetenv("MASTER_KEYS")) }() + defer func() { require.NoError(t, os.Unsetenv("ACTIVE_MASTER_KEY_ID")) }() + + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: "base64key://test", + } + + mockKeeper := &mockKMSKeeper{ + decryptFunc: func(ctx context.Context, ciphertext []byte) ([]byte, error) { + return nil, assert.AnError + }, + closeFunc: func() error { return nil }, + } + + mockKMS := &mockKMSService{ + openKeeperFunc: func(ctx context.Context, keyURI string) (KMSKeeper, error) { + return mockKeeper, nil + }, + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, mockKMS, logger) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrKMSDecryptionFailed) + assert.Nil(t, mkc) +} + +func TestLoadMasterKeyChain_KMSMode_InvalidKeySize(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + + ciphertext1Base64 := base64.StdEncoding.EncodeToString([]byte("encrypted-short")) + + require.NoError(t, os.Setenv("MASTER_KEYS", "key1:"+ciphertext1Base64)) + require.NoError(t, os.Setenv("ACTIVE_MASTER_KEY_ID", "key1")) + defer func() { require.NoError(t, os.Unsetenv("MASTER_KEYS")) }() + defer func() { require.NoError(t, os.Unsetenv("ACTIVE_MASTER_KEY_ID")) }() + + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: "base64key://test", + } + + mockKeeper := &mockKMSKeeper{ + decryptFunc: func(ctx context.Context, ciphertext []byte) ([]byte, error) { + // Return key that's too short (not 32 bytes) + if len(ciphertext) > 10 && string(ciphertext[:10]) == "encrypted-" { + // Return a copy to prevent issues when ciphertext is zeroed + plaintext := make([]byte, len(ciphertext)-10) + copy(plaintext, ciphertext[10:]) + return plaintext, nil + } + return nil, assert.AnError + }, + closeFunc: func() error { return nil }, + } + + mockKMS := &mockKMSService{ + openKeeperFunc: func(ctx context.Context, keyURI string) (KMSKeeper, error) { + return mockKeeper, nil + }, + } + + mkc, err := LoadMasterKeyChain(ctx, cfg, mockKMS, logger) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidKeySize) + assert.Nil(t, mkc) +} diff --git a/internal/crypto/service/kms_service.go b/internal/crypto/service/kms_service.go new file mode 100644 index 0000000..1b6bac6 --- /dev/null +++ b/internal/crypto/service/kms_service.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "fmt" + + "gocloud.dev/secrets" + + cryptoDomain "github.com/allisson/secrets/internal/crypto/domain" + + // Register all KMS provider drivers + _ "gocloud.dev/secrets/awskms" + _ "gocloud.dev/secrets/azurekeyvault" + _ "gocloud.dev/secrets/gcpkms" + _ "gocloud.dev/secrets/hashivault" + _ "gocloud.dev/secrets/localsecrets" +) + +// KMSService implements domain.KMSService for KMS operations using gocloud.dev/secrets. +type KMSService interface { + // OpenKeeper opens a secrets.Keeper for the configured KMS provider. + // Returns an error if the KMS provider URI is invalid or connection fails. + OpenKeeper(ctx context.Context, keyURI string) (cryptoDomain.KMSKeeper, error) +} + +// kmsService implements KMSService using gocloud.dev/secrets. +type kmsService struct{} + +// NewKMSService creates a new KMS service instance. +func NewKMSService() KMSService { + return &kmsService{} +} + +// OpenKeeper opens a secrets.Keeper for the configured KMS provider using the keyURI. +// Supports: gcpkms://, awskms://, azurekeyvault://, hashivault://, base64key:// +// Returns a KMSKeeper which *secrets.Keeper implements. +func (k *kmsService) OpenKeeper(ctx context.Context, keyURI string) (cryptoDomain.KMSKeeper, error) { + keeper, err := secrets.OpenKeeper(ctx, keyURI) + if err != nil { + return nil, fmt.Errorf("failed to open KMS keeper: %w", err) + } + return keeper, nil +} diff --git a/internal/crypto/service/kms_service_test.go b/internal/crypto/service/kms_service_test.go new file mode 100644 index 0000000..8f75207 --- /dev/null +++ b/internal/crypto/service/kms_service_test.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/base64" + "testing" + + "gocloud.dev/secrets" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateLocalSecretsURI generates a base64key:// URI for testing. +func generateLocalSecretsURI(t *testing.T) string { + t.Helper() + key := make([]byte, 32) + _, err := rand.Read(key) + require.NoError(t, err) + return "base64key://" + base64.URLEncoding.EncodeToString(key) +} + +func TestKMSService_OpenKeeper(t *testing.T) { + ctx := context.Background() + kmsService := NewKMSService() + + t.Run("Success_LocalSecrets", func(t *testing.T) { + keyURI := generateLocalSecretsURI(t) + + keeper, err := kmsService.OpenKeeper(ctx, keyURI) + require.NoError(t, err) + require.NotNil(t, keeper) + + // Verify it's actually a *secrets.Keeper + _, ok := keeper.(*secrets.Keeper) + assert.True(t, ok, "keeper should be *secrets.Keeper") + + // Cleanup + defer func() { + assert.NoError(t, keeper.Close()) + }() + }) + + t.Run("Error_InvalidURI", func(t *testing.T) { + invalidURI := "invalid://uri" + + keeper, err := kmsService.OpenKeeper(ctx, invalidURI) + assert.Error(t, err) + assert.Nil(t, keeper) + assert.Contains(t, err.Error(), "failed to open KMS keeper") + }) + + t.Run("Error_EmptyURI", func(t *testing.T) { + keeper, err := kmsService.OpenKeeper(ctx, "") + assert.Error(t, err) + assert.Nil(t, keeper) + }) +} + +func TestKMSService_KeeperDecryptFunctionality(t *testing.T) { + ctx := context.Background() + kmsService := NewKMSService() + keyURI := generateLocalSecretsURI(t) + + keeperInterface, err := kmsService.OpenKeeper(ctx, keyURI) + require.NoError(t, err) + defer func() { + assert.NoError(t, keeperInterface.Close()) + }() + + // Type assert to get the actual *secrets.Keeper for Encrypt + keeper, ok := keeperInterface.(*secrets.Keeper) + require.True(t, ok, "keeper should be *secrets.Keeper") + + testCases := []struct { + name string + plaintext []byte + }{ + { + name: "ShortText", + plaintext: []byte("hello"), + }, + { + name: "LongText", + plaintext: []byte( + "This is a longer piece of text that should be encrypted and decrypted successfully", + ), + }, + { + name: "BinaryData", + plaintext: []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD}, + }, + { + name: "MasterKeySize", + plaintext: make([]byte, 32), // 32-byte master key + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Encrypt using the keeper + ciphertext, err := keeper.Encrypt(ctx, tc.plaintext) + require.NoError(t, err) + assert.NotEqual(t, tc.plaintext, ciphertext) + + // Decrypt using the keeper interface (as used by domain layer) + decrypted, err := keeperInterface.Decrypt(ctx, ciphertext) + require.NoError(t, err) + assert.Equal(t, tc.plaintext, decrypted) + }) + } +} + +func TestKMSService_DecryptInvalidCiphertext(t *testing.T) { + ctx := context.Background() + kmsService := NewKMSService() + keyURI := generateLocalSecretsURI(t) + + keeper, err := kmsService.OpenKeeper(ctx, keyURI) + require.NoError(t, err) + defer func() { + assert.NoError(t, keeper.Close()) + }() + + invalidCiphertext := []byte("not a valid ciphertext") + + decrypted, err := keeper.Decrypt(ctx, invalidCiphertext) + assert.Error(t, err) + assert.Nil(t, decrypted) +} + +func TestKMSService_MultipleKeepers(t *testing.T) { + ctx := context.Background() + kmsService := NewKMSService() + + // Create two different keepers with different keys + keyURI1 := generateLocalSecretsURI(t) + keyURI2 := generateLocalSecretsURI(t) + + keeper1Interface, err := kmsService.OpenKeeper(ctx, keyURI1) + require.NoError(t, err) + defer func() { + assert.NoError(t, keeper1Interface.Close()) + }() + + keeper2Interface, err := kmsService.OpenKeeper(ctx, keyURI2) + require.NoError(t, err) + defer func() { + assert.NoError(t, keeper2Interface.Close()) + }() + + // Type assert to encrypt + keeper1, ok := keeper1Interface.(*secrets.Keeper) + require.True(t, ok) + + plaintext := []byte("test data") + + // Encrypt with keeper1 + ciphertext, err := keeper1.Encrypt(ctx, plaintext) + require.NoError(t, err) + + // Decrypt with keeper1 should succeed + decrypted1, err := keeper1Interface.Decrypt(ctx, ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted1) + + // Decrypt with keeper2 should fail (different key) + decrypted2, err := keeper2Interface.Decrypt(ctx, ciphertext) + assert.Error(t, err) + assert.Nil(t, decrypted2) +} diff --git a/test/integration/api_test.go b/test/integration/api_test.go index 602b5aa..a58d0a3 100644 --- a/test/integration/api_test.go +++ b/test/integration/api_test.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/http/httptest" "os" @@ -21,12 +22,15 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gocloud.dev/secrets" + _ "gocloud.dev/secrets/localsecrets" "github.com/allisson/secrets/internal/app" authDomain "github.com/allisson/secrets/internal/auth/domain" authDTO "github.com/allisson/secrets/internal/auth/http/dto" "github.com/allisson/secrets/internal/config" cryptoDomain "github.com/allisson/secrets/internal/crypto/domain" + cryptoService "github.com/allisson/secrets/internal/crypto/service" secretsDTO "github.com/allisson/secrets/internal/secrets/http/dto" "github.com/allisson/secrets/internal/testutil" tokenizationDTO "github.com/allisson/secrets/internal/tokenization/http/dto" @@ -43,6 +47,7 @@ type integrationTestContext struct { rootSecret string masterKeyChain *cryptoDomain.MasterKeyChain dbDriver string + kmsKeyURI string } // makeRequest performs an HTTP request and returns the response and body. @@ -117,6 +122,180 @@ func createMasterKeyChain(masterKey *cryptoDomain.MasterKey) *cryptoDomain.Maste return chain } +// generateLocalSecretsKMSKey creates a random 32-byte key and returns a base64key:// URI for testing. +func generateLocalSecretsKMSKey(t *testing.T) string { + t.Helper() + key := make([]byte, 32) + _, err := rand.Read(key) + require.NoError(t, err, "failed to generate KMS key") + return "base64key://" + base64.URLEncoding.EncodeToString(key) +} + +// createMasterKeyChainWithKMS creates a master key chain with KMS-encrypted master keys. +func createMasterKeyChainWithKMS( + ctx context.Context, + t *testing.T, + masterKey *cryptoDomain.MasterKey, + kmsKeyURI string, +) *cryptoDomain.MasterKeyChain { + t.Helper() + + // Open KMS keeper + kmsService := cryptoService.NewKMSService() + keeperInterface, err := kmsService.OpenKeeper(ctx, kmsKeyURI) + require.NoError(t, err, "failed to open KMS keeper") + defer func() { + assert.NoError(t, keeperInterface.Close()) + }() + + // Type assert to get Encrypt method + keeper, ok := keeperInterface.(*secrets.Keeper) + require.True(t, ok, "keeper should be *secrets.Keeper") + + // Encrypt master key with KMS + ciphertext, err := keeper.Encrypt(ctx, masterKey.Key) + require.NoError(t, err, "failed to encrypt master key with KMS") + + // Encode ciphertext to base64 + encodedCiphertext := base64.StdEncoding.EncodeToString(ciphertext) + + // Set environment variables + err = os.Setenv("MASTER_KEYS", fmt.Sprintf("%s:%s", masterKey.ID, encodedCiphertext)) + require.NoError(t, err, "failed to set MASTER_KEYS env") + + err = os.Setenv("ACTIVE_MASTER_KEY_ID", masterKey.ID) + require.NoError(t, err, "failed to set ACTIVE_MASTER_KEY_ID env") + + err = os.Setenv("KMS_PROVIDER", "localsecrets") + require.NoError(t, err, "failed to set KMS_PROVIDER env") + + err = os.Setenv("KMS_KEY_URI", kmsKeyURI) + require.NoError(t, err, "failed to set KMS_KEY_URI env") + + // Load master key chain using KMS + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: kmsKeyURI, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + chain, err := cryptoDomain.LoadMasterKeyChain(ctx, cfg, kmsService, logger) + require.NoError(t, err, "failed to load master key chain from KMS") + + return chain +} + +// setupIntegrationTestWithKMS initializes all components for integration testing with KMS-encrypted master keys. +func setupIntegrationTestWithKMS(t *testing.T, dbDriver string) *integrationTestContext { + t.Helper() + + // Set Gin to test mode + gin.SetMode(gin.TestMode) + + // Setup database + var db *sql.DB + var dsn string + if dbDriver == "postgres" { + db = testutil.SetupPostgresDB(t) + dsn = testutil.PostgresTestDSN + } else { + db = testutil.SetupMySQLDB(t) + dsn = testutil.MySQLTestDSN + } + + // Generate KMS key URI and ephemeral master key + kmsKeyURI := generateLocalSecretsKMSKey(t) + masterKey := generateMasterKey() + masterKeyChain := createMasterKeyChainWithKMS(context.Background(), t, masterKey, kmsKeyURI) + + // Create configuration with KMS settings + cfg := &config.Config{ + DBDriver: dbDriver, + DBConnectionString: dsn, + DBMaxOpenConnections: 10, + DBMaxIdleConnections: 5, + DBConnMaxLifetime: time.Hour, + ServerHost: "localhost", + ServerPort: 8080, + LogLevel: "error", + AuthTokenExpiration: time.Hour, + KMSProvider: "localsecrets", + KMSKeyURI: kmsKeyURI, + } + + // Create DI container + container := app.NewContainer(cfg) + + // Initialize KEK + kekUseCase, err := container.KekUseCase() + require.NoError(t, err, "failed to get kek use case") + + err = kekUseCase.Create(context.Background(), masterKeyChain, cryptoDomain.AESGCM) + require.NoError(t, err, "failed to create initial KEK") + + // Create root client with all capabilities + clientUseCase, err := container.ClientUseCase() + require.NoError(t, err, "failed to get client use case") + + rootClientInput := &authDomain.CreateClientInput{ + Name: "Root Integration Test Client (KMS)", + IsActive: true, + Policies: []authDomain.PolicyDocument{ + { + Path: "*", + Capabilities: []authDomain.Capability{ + authDomain.ReadCapability, + authDomain.WriteCapability, + authDomain.DeleteCapability, + authDomain.EncryptCapability, + authDomain.DecryptCapability, + authDomain.RotateCapability, + }, + }, + }, + } + + rootClientOutput, err := clientUseCase.Create(context.Background(), rootClientInput) + require.NoError(t, err, "failed to create root client") + + rootClient, err := clientUseCase.Get(context.Background(), rootClientOutput.ID) + require.NoError(t, err, "failed to get root client") + + // Issue token for root client + tokenUseCase, err := container.TokenUseCase() + require.NoError(t, err, "failed to get token use case") + + issueTokenInput := &authDomain.IssueTokenInput{ + ClientID: rootClientOutput.ID, + ClientSecret: rootClientOutput.PlainSecret, + } + + tokenOutput, err := tokenUseCase.Issue(context.Background(), issueTokenInput) + require.NoError(t, err, "failed to issue token") + + // Setup HTTP server + httpSrv, err := container.HTTPServer() + require.NoError(t, err, "failed to get HTTP server") + + handler := httpSrv.GetHandler() + require.NotNil(t, handler, "handler should not be nil after SetupRouter") + + testServer := httptest.NewServer(handler) + + t.Logf("Integration test setup complete for %s with KMS (client_id=%s)", dbDriver, rootClient.ID) + + return &integrationTestContext{ + container: container, + db: db, + server: testServer, + rootClient: rootClient, + rootToken: tokenOutput.PlainToken, + rootSecret: rootClientOutput.PlainSecret, + masterKeyChain: masterKeyChain, + dbDriver: dbDriver, + kmsKeyURI: kmsKeyURI, + } +} + // setupIntegrationTest initializes all components for integration testing. func setupIntegrationTest(t *testing.T, dbDriver string) *integrationTestContext { t.Helper() @@ -259,6 +438,12 @@ func teardownIntegrationTest(t *testing.T, ctx *integrationTestContext) { if err := os.Unsetenv("ACTIVE_MASTER_KEY_ID"); err != nil { t.Logf("Warning: failed to unset ACTIVE_MASTER_KEY_ID: %v", err) } + if err := os.Unsetenv("KMS_PROVIDER"); err != nil { + t.Logf("Warning: failed to unset KMS_PROVIDER: %v", err) + } + if err := os.Unsetenv("KMS_KEY_URI"); err != nil { + t.Logf("Warning: failed to unset KMS_KEY_URI: %v", err) + } t.Logf("Integration test teardown complete for %s", ctx.dbDriver) } @@ -1281,3 +1466,244 @@ func TestIntegration_Tokenization_CompleteFlow(t *testing.T) { }) } } + +// TestIntegration_KMS_CompleteFlow tests KMS master key encryption and lifecycle management. +// Validates KMS-encrypted master key loading, KEK operations, secret encryption/decryption, +// and master key rotation with backward compatibility across both database engines. +func TestIntegration_KMS_CompleteFlow(t *testing.T) { + // Skip if short mode (integration tests can be slow) + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + testCases := []struct { + name string + dbDriver string + }{ + {"PostgreSQL", "postgres"}, + {"MySQL", "mysql"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup with KMS + ctx := setupIntegrationTestWithKMS(t, tc.dbDriver) + defer teardownIntegrationTest(t, ctx) + + var ( + //nolint:gosec // test data path, not credentials + secretPath = "/kms-test/password" + secretPathStored = "kms-test/password" + plaintextValue = []byte("kms-protected-secret-value") + plaintextBase64 = base64.StdEncoding.EncodeToString(plaintextValue) + ) + + // [1/7] Verify KMS master key loaded + t.Run("01_VerifyKMSMasterKeyLoaded", func(t *testing.T) { + // Verify master key chain is not nil + assert.NotNil(t, ctx.masterKeyChain) + + // Verify active master key exists + activeKey, exists := ctx.masterKeyChain.Get(ctx.masterKeyChain.ActiveMasterKeyID()) + assert.True(t, exists, "active master key should exist") + assert.NotNil(t, activeKey, "active master key should not be nil") + assert.Equal(t, "test-key-1", activeKey.ID) + + t.Logf("KMS master key loaded: id=%s", activeKey.ID) + }) + + // [2/7] Verify KEK created with KMS master key + t.Run("02_VerifyKEKCreated", func(t *testing.T) { + // KEK was created during setup - verify it exists in database + // This validates KMS-decrypted master key successfully encrypted KEK + + kekUseCase, err := ctx.container.KekUseCase() + require.NoError(t, err) + + kekChain, err := kekUseCase.Unwrap(context.Background(), ctx.masterKeyChain) + require.NoError(t, err) + require.NotNil(t, kekChain, "KEK chain should not be nil") + + // Verify at least one KEK exists + activeKek, exists := kekChain.Get(kekChain.ActiveKekID()) + assert.True(t, exists, "active KEK should exist") + assert.NotNil(t, activeKek, "active KEK should not be nil") + + t.Logf("KEK created with KMS-protected master key: version=%d", activeKek.Version) + }) + + // [3/7] Create secret (encrypt with KMS-protected KEK) + t.Run("03_CreateSecret", func(t *testing.T) { + requestBody := secretsDTO.CreateOrUpdateSecretRequest{ + Value: plaintextBase64, + } + + resp, body := ctx.makeRequest(t, http.MethodPost, "/v1/secrets"+secretPath, requestBody, true) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var response secretsDTO.SecretResponse + err := json.Unmarshal(body, &response) + require.NoError(t, err) + assert.NotEmpty(t, response.ID) + assert.Equal(t, secretPathStored, response.Path) + assert.Equal(t, uint(1), response.Version) + + t.Logf("Secret encrypted through KMS chain: path=%s", secretPath) + }) + + // [4/7] Read secret (decrypt with KMS-protected KEK) + t.Run("04_ReadSecret", func(t *testing.T) { + resp, body := ctx.makeRequest(t, http.MethodGet, "/v1/secrets"+secretPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response secretsDTO.SecretResponse + err := json.Unmarshal(body, &response) + require.NoError(t, err) + assert.Equal(t, secretPathStored, response.Path) + assert.Equal(t, uint(1), response.Version) + assert.Equal(t, plaintextBase64, response.Value) + + // Verify decryption worked correctly + decoded, err := base64.StdEncoding.DecodeString(response.Value) + require.NoError(t, err) + assert.Equal(t, plaintextValue, decoded) + + t.Logf("Secret decrypted through KMS chain: verified") + }) + + // [5/7] Rotate master key with KMS + t.Run("05_RotateMasterKeyWithKMS", func(t *testing.T) { + // Generate new master key + newMasterKey := &cryptoDomain.MasterKey{ + ID: "test-key-2", + Key: make([]byte, 32), + } + _, err := rand.Read(newMasterKey.Key) + require.NoError(t, err) + + // Encrypt new master key with KMS + kmsService := cryptoService.NewKMSService() + keeperInterface, err := kmsService.OpenKeeper(context.Background(), ctx.kmsKeyURI) + require.NoError(t, err) + defer func() { + assert.NoError(t, keeperInterface.Close()) + }() + + keeper, ok := keeperInterface.(*secrets.Keeper) + require.True(t, ok) + + newCiphertext, err := keeper.Encrypt(context.Background(), newMasterKey.Key) + require.NoError(t, err) + newEncodedCiphertext := base64.StdEncoding.EncodeToString(newCiphertext) + + // Get old master key ciphertext from environment + oldMasterKeys := os.Getenv("MASTER_KEYS") + + // Update MASTER_KEYS with both old and new (comma-separated) + dualKeys := fmt.Sprintf("%s,%s:%s", oldMasterKeys, newMasterKey.ID, newEncodedCiphertext) + err = os.Setenv("MASTER_KEYS", dualKeys) + require.NoError(t, err) + + err = os.Setenv("ACTIVE_MASTER_KEY_ID", newMasterKey.ID) + require.NoError(t, err) + + // Reload master key chain with both keys + cfg := &config.Config{ + KMSProvider: "localsecrets", + KMSKeyURI: ctx.kmsKeyURI, + } + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}), + ) + + // Close old chain before loading new one + ctx.masterKeyChain.Close() + + newChain, err := cryptoDomain.LoadMasterKeyChain( + context.Background(), + cfg, + kmsService, + logger, + ) + require.NoError(t, err) + ctx.masterKeyChain = newChain + + // Verify both keys loaded + oldKey, oldExists := ctx.masterKeyChain.Get("test-key-1") + assert.True(t, oldExists, "old master key should still exist") + assert.NotNil(t, oldKey) + + activeKey, activeExists := ctx.masterKeyChain.Get("test-key-2") + assert.True(t, activeExists, "new master key should exist") + assert.NotNil(t, activeKey) + assert.Equal(t, "test-key-2", ctx.masterKeyChain.ActiveMasterKeyID()) + + t.Logf("Master key rotated: old=%s, new=%s (active)", "test-key-1", "test-key-2") + }) + + // [6/7] Verify dual master key support (backward compatibility) + t.Run("06_VerifyBackwardCompatibility", func(t *testing.T) { + // Read old secret encrypted with old master key + resp, body := ctx.makeRequest(t, http.MethodGet, "/v1/secrets"+secretPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response secretsDTO.SecretResponse + err := json.Unmarshal(body, &response) + require.NoError(t, err) + assert.Equal(t, plaintextBase64, response.Value) + + // Verify old secret still decrypts correctly + decoded, err := base64.StdEncoding.DecodeString(response.Value) + require.NoError(t, err) + assert.Equal(t, plaintextValue, decoded) + + t.Logf("Old secret decrypts after rotation: backward compatibility verified") + }) + + // [7/7] Create secret after rotation (uses new master key) + t.Run("07_CreateSecretAfterRotation", func(t *testing.T) { + //nolint:gosec // test data paths, not credentials + newSecretPath := "/kms-test/new-secret" + //nolint:gosec // test data paths, not credentials + newSecretPathStored := "kms-test/new-secret" + newPlaintext := []byte("secret-created-after-rotation") + newPlaintextBase64 := base64.StdEncoding.EncodeToString(newPlaintext) + + requestBody := secretsDTO.CreateOrUpdateSecretRequest{ + Value: newPlaintextBase64, + } + + resp, body := ctx.makeRequest( + t, + http.MethodPost, + "/v1/secrets"+newSecretPath, + requestBody, + true, + ) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var createResponse secretsDTO.SecretResponse + err := json.Unmarshal(body, &createResponse) + require.NoError(t, err) + assert.Equal(t, newSecretPathStored, createResponse.Path) + + // Read back and verify + resp, body = ctx.makeRequest(t, http.MethodGet, "/v1/secrets"+newSecretPath, nil, true) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var readResponse secretsDTO.SecretResponse + err = json.Unmarshal(body, &readResponse) + require.NoError(t, err) + assert.Equal(t, newPlaintextBase64, readResponse.Value) + + decoded, err := base64.StdEncoding.DecodeString(readResponse.Value) + require.NoError(t, err) + assert.Equal(t, newPlaintext, decoded) + + t.Logf("New secret created with rotated master key: verified") + }) + + t.Logf("All 7 KMS endpoint tests passed for %s", tc.dbDriver) + }) + } +}