- Enhance audit checkpoint system - Update KMS provider and tenant key management - Refine configuration constants - Improve TUI config handling
395 lines
13 KiB
Go
395 lines
13 KiB
Go
// Package crypto provides tenant-scoped encryption key management for multi-tenant deployments.
|
|
// This implements Phase 9.4: Per-Tenant Encryption Keys with KMS integration per ADR-012 through ADR-015.
|
|
package crypto
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jfraeys/fetch_ml/internal/audit"
|
|
"github.com/jfraeys/fetch_ml/internal/crypto/kms"
|
|
)
|
|
|
|
// KeyHierarchy defines the tenant key structure
|
|
// Root Key (per tenant in KMS) -> Data Encryption Keys (per artifact, cached per ADR-012)
|
|
type KeyHierarchy struct {
|
|
TenantID string `json:"tenant_id"`
|
|
RootKeyID string `json:"root_key_id"`
|
|
KMSKeyID string `json:"kms_key_id"` // External KMS key identifier per ADR-014
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Algorithm string `json:"algorithm"` // Always "AES-256-GCM"
|
|
}
|
|
|
|
// TenantKeyManager manages per-tenant encryption keys using external KMS per ADR-012 through ADR-015.
|
|
// Root keys are stored in the KMS; DEKs are generated locally and cached.
|
|
type TenantKeyManager struct {
|
|
kms kms.KMSProvider // External KMS for root key operations
|
|
cache *kms.DEKCache // In-process DEK cache per ADR-012
|
|
config kms.Config // KMS configuration
|
|
ctx context.Context // Background context for operations
|
|
audit *audit.Logger // Audit logger for key operations per ADR-012
|
|
}
|
|
|
|
// NewTenantKeyManager creates a new tenant key manager with KMS integration.
|
|
func NewTenantKeyManager(provider kms.KMSProvider, cache *kms.DEKCache, config kms.Config, auditLogger *audit.Logger) *TenantKeyManager {
|
|
return &TenantKeyManager{
|
|
kms: provider,
|
|
cache: cache,
|
|
config: config,
|
|
ctx: context.Background(),
|
|
audit: auditLogger,
|
|
}
|
|
}
|
|
|
|
// ProvisionTenant creates a new root key for a tenant in the KMS.
|
|
func (km *TenantKeyManager) ProvisionTenant(tenantID string) (*KeyHierarchy, error) {
|
|
if strings.TrimSpace(tenantID) == "" {
|
|
return nil, fmt.Errorf("tenant ID cannot be empty")
|
|
}
|
|
|
|
// Create KMS key for tenant
|
|
kmsKeyID, err := km.kms.CreateKey(km.ctx, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create KMS key: %w", err)
|
|
}
|
|
|
|
// Create key ID from hash of tenant ID + timestamp
|
|
h := sha256.Sum256([]byte(tenantID + time.Now().String()))
|
|
rootKeyID := hex.EncodeToString(h[:8]) // First 8 bytes as ID
|
|
|
|
// Log key creation per ADR-012
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSKeyCreate, tenantID, "", kmsKeyID, true, "")
|
|
}
|
|
|
|
return &KeyHierarchy{
|
|
TenantID: tenantID,
|
|
RootKeyID: rootKeyID,
|
|
KMSKeyID: kmsKeyID,
|
|
CreatedAt: time.Now().UTC(),
|
|
Algorithm: "AES-256-GCM",
|
|
}, nil
|
|
}
|
|
|
|
// RotateTenantKey rotates the root key for a tenant.
|
|
// Creates new KMS key and schedules deletion of old key per ADR-015.
|
|
func (km *TenantKeyManager) RotateTenantKey(tenantID string, hierarchy *KeyHierarchy) (*KeyHierarchy, error) {
|
|
// Schedule deletion of old key (90 day window per ADR-015)
|
|
_, err := km.kms.ScheduleKeyDeletion(km.ctx, hierarchy.KMSKeyID, 90)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to schedule old key deletion: %w", err)
|
|
}
|
|
|
|
// Flush DEK cache for this tenant
|
|
km.cache.Flush(tenantID)
|
|
|
|
// Provision new key
|
|
newHierarchy, err := km.ProvisionTenant(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log key rotation per ADR-012
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSKeyRotate, tenantID, "", newHierarchy.KMSKeyID, true, "")
|
|
}
|
|
|
|
return newHierarchy, nil
|
|
}
|
|
|
|
// RevokeTenant disables and schedules deletion of all keys for a tenant.
|
|
// This effectively makes all encrypted data inaccessible per ADR-015.
|
|
func (km *TenantKeyManager) RevokeTenant(hierarchy *KeyHierarchy) error {
|
|
// Immediately disable the key per ADR-015
|
|
if err := km.kms.DisableKey(km.ctx, hierarchy.KMSKeyID); err != nil {
|
|
return fmt.Errorf("failed to disable key: %w", err)
|
|
}
|
|
|
|
// Log key disable per ADR-015
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSKeyDisable, hierarchy.TenantID, "", hierarchy.KMSKeyID, true, "")
|
|
}
|
|
|
|
// Schedule hard deletion after 90 days per ADR-015
|
|
_, err := km.kms.ScheduleKeyDeletion(km.ctx, hierarchy.KMSKeyID, 90)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to schedule key deletion: %w", err)
|
|
}
|
|
|
|
// Log key deletion scheduled per ADR-015
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSKeyDelete, hierarchy.TenantID, "", hierarchy.KMSKeyID, true, "")
|
|
}
|
|
|
|
// Flush DEK cache for this tenant
|
|
km.cache.Flush(hierarchy.TenantID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateDataEncryptionKey creates a unique DEK for an artifact.
|
|
// The DEK is wrapped (encrypted) under the tenant's KMS root key.
|
|
func (km *TenantKeyManager) GenerateDataEncryptionKey(tenantID, artifactID, kmsKeyID string) (*WrappedDEK, error) {
|
|
// Generate unique DEK (32 bytes for AES-256)
|
|
dek := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
|
|
return nil, fmt.Errorf("failed to generate DEK: %w", err)
|
|
}
|
|
|
|
// Wrap DEK with KMS root key
|
|
wrappedKey, err := km.wrapKeyWithKMS(km.ctx, kmsKeyID, dek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to wrap DEK: %w", err)
|
|
}
|
|
|
|
// Store DEK in cache for future use per ADR-012
|
|
if err := km.cache.Put(tenantID, artifactID, kmsKeyID, dek); err != nil {
|
|
// Log but don't fail - caching is optimization
|
|
_ = err
|
|
}
|
|
|
|
// Clear plaintext DEK from memory
|
|
for i := range dek {
|
|
dek[i] = 0
|
|
}
|
|
|
|
return &WrappedDEK{
|
|
TenantID: tenantID,
|
|
ArtifactID: artifactID,
|
|
WrappedKey: wrappedKey,
|
|
Algorithm: "AES-256-GCM",
|
|
CreatedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
// wrapKeyWithKMS encrypts a key using the KMS.
|
|
func (km *TenantKeyManager) wrapKeyWithKMS(ctx context.Context, kmsKeyID string, keyToWrap []byte) (string, error) {
|
|
ciphertext, err := km.kms.Encrypt(ctx, kmsKeyID, keyToWrap)
|
|
if err != nil {
|
|
return "", fmt.Errorf("KMS encrypt failed: %w", err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
// UnwrapDataEncryptionKey decrypts a wrapped DEK using the tenant's KMS root key.
|
|
// Per ADR-012/013: Checks cache first, falls back to KMS with fail-closed grace window.
|
|
func (km *TenantKeyManager) UnwrapDataEncryptionKey(wrappedDEK *WrappedDEK, kmsKeyID string) ([]byte, error) {
|
|
// Try cache first per ADR-012 - include KMSKeyID in cache key for isolation
|
|
if dek, ok := km.cache.Get(wrappedDEK.TenantID, wrappedDEK.ArtifactID, kmsKeyID, false); ok {
|
|
return dek, nil
|
|
}
|
|
|
|
// Check KMS health for grace window determination per ADR-013
|
|
kmsHealthy := km.kms.HealthCheck(km.ctx) == nil
|
|
|
|
// If KMS is unavailable and we have a cached entry in grace window, use it per ADR-013
|
|
if !kmsHealthy {
|
|
if dek, ok := km.cache.Get(wrappedDEK.TenantID, wrappedDEK.ArtifactID, kmsKeyID, true); ok {
|
|
// Grace window DEK returned - logged by caller
|
|
return dek, nil
|
|
}
|
|
// No cached DEK and KMS unavailable - fail closed per ADR-013
|
|
return nil, fmt.Errorf("KMS unavailable and no cached DEK (fail-closed per ADR-013)")
|
|
}
|
|
|
|
// Unwrap via KMS
|
|
ciphertext, err := base64.StdEncoding.DecodeString(wrappedDEK.WrappedKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode wrapped key: %w", err)
|
|
}
|
|
|
|
dek, err := km.kms.Decrypt(km.ctx, kmsKeyID, ciphertext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("KMS decrypt failed: %w", err)
|
|
}
|
|
|
|
// Store in cache for future use per ADR-012 - include KMSKeyID
|
|
if err := km.cache.Put(wrappedDEK.TenantID, wrappedDEK.ArtifactID, kmsKeyID, dek); err != nil {
|
|
// Log but don't fail - caching is optimization
|
|
_ = err
|
|
}
|
|
|
|
return dek, nil
|
|
}
|
|
|
|
// WrappedDEK represents a data encryption key wrapped under a tenant root key
|
|
type WrappedDEK struct {
|
|
TenantID string `json:"tenant_id"`
|
|
ArtifactID string `json:"artifact_id"`
|
|
WrappedKey string `json:"wrapped_key"` // base64 encoded
|
|
Algorithm string `json:"algorithm"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// NewTestTenantKeyManager creates a tenant key manager with memory provider for testing.
|
|
// This provides backward compatibility for existing tests.
|
|
func NewTestTenantKeyManager(auditLogger *audit.Logger) *TenantKeyManager {
|
|
provider := kms.NewMemoryProvider()
|
|
cache := kms.NewDEKCache(kms.DefaultCacheConfig())
|
|
config := kms.Config{Provider: kms.ProviderTypeMemory}
|
|
return NewTenantKeyManager(provider, cache, config, auditLogger)
|
|
}
|
|
|
|
// EncryptArtifact encrypts artifact data using a tenant-specific DEK.
|
|
func (km *TenantKeyManager) EncryptArtifact(tenantID, artifactID, kmsKeyID string, plaintext []byte) (*EncryptedArtifact, error) {
|
|
// Generate a new DEK for this artifact
|
|
wrappedDEK, err := km.GenerateDataEncryptionKey(tenantID, artifactID, kmsKeyID)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSEncrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Get the DEK (from cache or unwrap)
|
|
dek, err := km.UnwrapDataEncryptionKey(wrappedDEK, kmsKeyID)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSEncrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
// Clear DEK from memory after use
|
|
for i := range dek {
|
|
dek[i] = 0
|
|
}
|
|
}()
|
|
|
|
// Encrypt the data with the DEK
|
|
block, err := aes.NewCipher(dek)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSEncrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSEncrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSEncrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
|
|
// Log successful encryption per ADR-012
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSEncrypt, tenantID, artifactID, kmsKeyID, true, "")
|
|
}
|
|
|
|
return &EncryptedArtifact{
|
|
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
|
DEK: wrappedDEK,
|
|
KMSKeyID: kmsKeyID,
|
|
Algorithm: "AES-256-GCM",
|
|
}, nil
|
|
}
|
|
|
|
// DecryptArtifact decrypts artifact data using its wrapped DEK.
|
|
func (km *TenantKeyManager) DecryptArtifact(encrypted *EncryptedArtifact, kmsKeyID string) ([]byte, error) {
|
|
tenantID := encrypted.DEK.TenantID
|
|
artifactID := encrypted.DEK.ArtifactID
|
|
|
|
// Unwrap the DEK
|
|
dek, err := km.UnwrapDataEncryptionKey(encrypted.DEK, kmsKeyID)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to unwrap DEK: %w", err)
|
|
}
|
|
defer func() {
|
|
for i := range dek {
|
|
dek[i] = 0
|
|
}
|
|
}()
|
|
|
|
// Decrypt the data
|
|
ciphertext, err := base64.StdEncoding.DecodeString(encrypted.Ciphertext)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to decode ciphertext: %w", err)
|
|
}
|
|
|
|
block, err := aes.NewCipher(dek)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
|
}
|
|
|
|
nonceSize := gcm.NonceSize()
|
|
if len(ciphertext) < nonceSize {
|
|
err := fmt.Errorf("ciphertext too short")
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, false, err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Log successful decryption per ADR-012
|
|
if km.audit != nil {
|
|
km.audit.LogKMSOperation(audit.EventKMSDecrypt, tenantID, artifactID, kmsKeyID, true, "")
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// EncryptedArtifact represents an encrypted artifact with its wrapped DEK
|
|
type EncryptedArtifact struct {
|
|
Ciphertext string `json:"ciphertext"` // base64 encoded
|
|
DEK *WrappedDEK `json:"dek"`
|
|
KMSKeyID string `json:"kms_key_id"` // Per ADR-014
|
|
Algorithm string `json:"algorithm"`
|
|
}
|
|
|
|
// AuditLogEntry represents an audit log entry for encryption/decryption operations
|
|
type AuditLogEntry struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Operation string `json:"operation"` // "encrypt", "decrypt", "key_rotation"
|
|
TenantID string `json:"tenant_id"`
|
|
ArtifactID string `json:"artifact_id,omitempty"`
|
|
KeyID string `json:"key_id"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|