Module: Familia::Features::EncryptedFields

Defined in:
lib/familia/features/encrypted_fields.rb

Overview

EncryptedFields is a feature that provides transparent encryption and decryption of sensitive data stored in Valkey/Redis. It uses strong cryptographic algorithms with field-specific key derivation to protect data at rest while maintaining easy access patterns for authorized applications.

This feature automatically encrypts field values before storage and decrypts them on access, providing seamless integration with existing code while ensuring sensitive data is never stored in plaintext.

Supported Encryption Algorithms:

  • XChaCha20-Poly1305 (preferred, requires rbnacl gem)
  • AES-256-GCM (fallback, uses OpenSSL)

Example:

class Vault < Familia::Horreum feature :encrypted_fields

field :name                    # Regular unencrypted field
encrypted_field :secret_key    # Encrypted storage
encrypted_field :api_token     # Another encrypted field
encrypted_field :love_letter   # Ultra-sensitive field

end

vault = Vault.new( name: "Production Vault", secret_key: "super-secret-key-123", api_token: "sk-1234567890abcdef", love_letter: "Dear Alice, I love you. -Bob" )

vault.save # Only 'name' is stored in plaintext # secret_key, api_token, love_letter are encrypted

# Access is transparent vault.secret_key # => "super-secret-key-123" (decrypted automatically) vault.api_token # => "sk-1234567890abcdef" (decrypted automatically)

Security Features:

Each encrypted field uses a unique encryption key derived from:

  • Master encryption key (from Familia.encryption_keys[current_key_version])
  • Field name (cryptographic domain separation)
  • Record identifier (per-record key derivation)
  • Class name (per-class key derivation)

This ensures that:

  • Swapping encrypted values between fields fails to decrypt
  • Each record has unique encryption keys
  • Different classes cannot decrypt each other's data
  • Field-level access control is cryptographically enforced

Cryptographic Design:

# XChaCha20-Poly1305 (preferred)

  • 256-bit keys (32 bytes)
  • 192-bit nonces (24 bytes) - extended nonce space
  • 128-bit authentication tags (16 bytes)
  • BLAKE2b key derivation with personalization

# AES-256-GCM (fallback)

  • 256-bit keys (32 bytes)
  • 96-bit nonces (12 bytes) - standard GCM nonce
  • 128-bit authentication tags (16 bytes)
  • HKDF-SHA256 key derivation

Ciphertext Format:

Encrypted data is stored as JSON with algorithm-specific metadata:

{ "algorithm": "xchacha20poly1305", "nonce": "base64_encoded_nonce", "ciphertext": "base64_encoded_data", "auth_tag": "base64_encoded_tag", "key_version": "v1" }

Additional Authenticated Data (AAD):

For extra security, you can include other field values in the authentication:

class SecureDocument < Familia::Horreum feature :encrypted_fields

field :doc_id, :owner_id, :classification
encrypted_field :content, aad_fields: [:doc_id, :owner_id, :classification]

end

# The content can only be decrypted if doc_id, owner_id, and classification # values match those used during encryption

Request-Level Caching:

For performance optimization, enable key derivation caching per request:

Familia::Encryption.with_request_cache do vault.secret_key = "value1" vault.api_token = "value2" vault.save # Reuses derived keys within this block end

# Cache is automatically cleared when block exits # Or manually: Familia::Encryption.clear_request_cache!

Preventing Accidental Leakage:

Encrypted fields return ConcealedString objects to help prevent exposure.

secret = vault.secret_key secret.class # => ConcealedString puts secret # => "[CONCEALED]" (automatic redaction) secret.inspect # => "[CONCEALED]" (automatic redaction)

# Safe access pattern raw_value = secret.reveal # Returns actual decrypted string # Use raw_value carefully - avoid creating copies

# Check if cleared from memory available to Ruby runtime process. secret.cleared? # Returns true if wiped

# Explicit cleanup secret.clear! # Best-effort memory wiping

Error Handling:

The feature provides specific error types for different failure modes:

# Invalid ciphertext or tampering vault.secret_key # => Familia::EncryptionError: Authentication failed

# Missing encryption configuration Familia.config.encryption_keys = {} vault.secret_key # => Familia::EncryptionError: No encryption keys configured

# Invalid key version # Key exists in storage but not in current configuration vault.secret_key # => Familia::EncryptionError: Key version not found: v1

Configuration:

# Configure versioned encryption keys (required) Familia.configure do |config| config.encryption_keys = { v1: ENV['FAMILIA_ENCRYPTION_KEY'], v2: ENV['FAMILIA_ENCRYPTION_KEY_V2'] } config.current_key_version = :v2 config.encryption_personalization = 'MyApp-2024' # Optional (XChaCha20 only) end

# Validate configuration before use Familia::Encryption.validate_configuration!

Key Rotation:

The feature supports key versioning for seamless key rotation:

# Step 1: Add new key version while keeping old keys Familia.configure do |config| config.encryption_keys = { v1: old_key, v2: new_key } config.current_key_version = :v2 end

# Step 2: Objects decrypt with any valid key, encrypt with current key vault.secret_key = "new-secret" # Encrypted with v2 key vault.save

# Step 3: Re-encrypt existing records vault.re_encrypt_fields! # Uses current key version vault.save

# Step 4: After all data is re-encrypted, remove old key

Integration Patterns:

# Rails application class User < ApplicationRecord include Familia::Horreum feature :encrypted_fields

field :user_id
encrypted_field :credit_card_number
encrypted_field :ssn, aad_fields: [:user_id]

end

# API serialization (encrypted fields excluded by default) class UserSerializer def self.serialize(user) { id: user.user_id, created_at: user.created_at, # credit_card_number and ssn are NOT included } end end

# Background job processing class PaymentProcessor def process_payment(user_id) user = User.find(user_id)

  # Access encrypted field safely
  cc_number = user.credit_card_number.reveal
  # Process payment without storing plaintext
  payment_gateway.charge(cc_number, amount)

  # Clear sensitive data from memory
  user.credit_card_number.clear!
end

end

Performance Considerations:

  • Encryption/decryption adds ~1-5ms overhead per field
  • Key derivation is NOT cached by default for security
  • Use request-level caching for performance: with_request_cache { ... }
  • XChaCha20-Poly1305 is ~2x faster than AES-256-GCM
  • Memory allocation increases due to ciphertext expansion
  • Consider batching operations for high-throughput scenarios
  • Personalization only affects XChaCha20-Poly1305 BLAKE2b derivation

Security Limitations:

⚠️ Important: Ruby provides NO memory safety guarantees:

  • No secure memory wiping (best-effort only)
  • Garbage collector may copy secrets
  • String operations create uncontrolled copies
  • Memory dumps may contain plaintext secrets

For highly sensitive applications, consider:

  • External key management (HashiCorp Vault, AWS KMS)
  • Hardware Security Modules (HSMs)
  • Languages with secure memory handling
  • Dedicated cryptographic appliances

Threat Model:

✅ Protected Against:

  • Database compromise (encrypted data only)
  • Field value swapping (field-specific keys)
  • Cross-record attacks (record-specific keys)
  • Tampering (authenticated encryption)

❌ Not Protected Against:

  • Master key compromise (all data compromised)
  • Application memory compromise (plaintext in RAM)
  • Side-channel attacks (timing, power analysis)
  • Insider threats with application access

Defined Under Namespace

Modules: ModelClassMethods

Instance Method Summary collapse

Instance Method Details

#clear_encrypted_fields!void

This method returns an undefined value.

Clear all encrypted field values from memory

This method iterates through all encrypted fields and calls clear! on any ConcealedString instances. Use this for cleanup when the object is no longer needed.

Examples:

Clear all secrets when done

vault = Vault.new(secret_key: 'secret', api_token: 'token123')
# ... use vault ...
vault.clear_encrypted_fields!


366
367
368
369
370
371
# File 'lib/familia/features/encrypted_fields.rb', line 366

def clear_encrypted_fields!
  self.class.encrypted_fields.each do |field_name|
    field_value = instance_variable_get("@#{field_name}")
    field_value.clear! if field_value.respond_to?(:clear!)
  end
end

#encrypted_data?Boolean

Check if this instance has any encrypted fields with values

Returns:

  • (Boolean)

    true if any encrypted fields have values



346
347
348
349
350
351
# File 'lib/familia/features/encrypted_fields.rb', line 346

def encrypted_data?
  self.class.encrypted_fields.any? do |field_name|
    field_value = instance_variable_get("@#{field_name}")
    !field_value.nil?
  end
end

#encrypted_fields_cleared?Boolean

Check if all encrypted fields have been cleared from memory

Returns:

  • (Boolean)

    true if all encrypted fields are cleared, false otherwise



377
378
379
380
381
382
# File 'lib/familia/features/encrypted_fields.rb', line 377

def encrypted_fields_cleared?
  self.class.encrypted_fields.all? do |field_name|
    field_value = instance_variable_get("@#{field_name}")
    field_value.nil? || (field_value.respond_to?(:cleared?) && field_value.cleared?)
  end
end

#encrypted_fields_statusHash

Get encryption status for all encrypted fields

Returns a hash showing the encryption status of each encrypted field, useful for debugging and monitoring.

Examples:

Check encryption status

vault.encrypted_fields_status
# => {
#   secret_key: { encrypted: true, algorithm: "xchacha20poly1305", cleared: false },
#   api_token: { encrypted: true, algorithm: "aes-256-gcm", cleared: true }
# }

Returns:

  • (Hash)

    Hash with field names as keys and status information



425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/familia/features/encrypted_fields.rb', line 425

def encrypted_fields_status
  self.class.encrypted_fields.each_with_object({}) do |field_name, status|
    field_value = instance_variable_get("@#{field_name}")

    status[field_name] = if field_value.nil?
                           { encrypted: false, value: nil }
                         elsif field_value.respond_to?(:cleared?) && field_value.cleared?
                           { encrypted: true, cleared: true }
                         elsif field_value.respond_to?(:concealed?) && field_value.concealed?
                           { encrypted: true, algorithm: 'unknown', cleared: false }
                         else
                           { encrypted: false, value: '[CONCEALED]' }
                         end
  end
end

#re_encrypt_fields!Boolean

Re-encrypt all encrypted fields with current encryption settings

This method is useful for key rotation or algorithm upgrades. It decrypts all encrypted fields and re-encrypts them with the current encryption configuration.

Examples:

Re-encrypt after key rotation

vault.re_encrypt_fields!
vault.save

Returns:

  • (Boolean)

    true if re-encryption succeeded



396
397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/familia/features/encrypted_fields.rb', line 396

def re_encrypt_fields!
  self.class.encrypted_fields.each do |field_name|
    current_value = send(field_name)
    next if current_value.nil?

    # Force re-encryption by setting the value again
    if current_value.respond_to?(:value)
      send("#{field_name}=", current_value.value)
    else
      send("#{field_name}=", current_value)
    end
  end
  true
end