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 Redis/Valkey. 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_key) - 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
Passphrase Protection:
For ultra-sensitive fields, require user passphrases for decryption:
class PersonalVault < Familia::Horreum feature :encrypted_fields
field :user_id
encrypted_field :diary_entry # Ultra-sensitive
encrypted_field :photos # Ultra-sensitive end
vault = PersonalVault.new(user_id: 123, diary_entry: “Dear diary…”) vault.save
# Passphrase required for decryption diary = vault.diary_entry(passphrase_value: user_passphrase)
Memory Safety:
Encrypted fields return ConcealedString objects that provide memory protection:
secret = vault.secret_key secret.class # => ConcealedString puts secret # => “[CONCEALED]” (automatic redaction) secret.inspect # => “[CONCEALED]” (automatic redaction)
# Safe access pattern secret.expose do |value| # Use value directly without creating copies api_call(authorization: “Bearer #value”) end
# Direct access (use carefully) raw_value = secret.value # Returns actual decrypted string
# 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
# Wrong passphrase vault.diary_entry(passphrase_value: “wrong”) # => Familia::EncryptionError: Invalid passphrase
# Missing encryption key Familia.encryption_key = nil vault.secret_key # => Familia::EncryptionError: No encryption key configured
Configuration:
# Set master encryption key (required) Familia.configure do |config| config.encryption_key = ENV[‘FAMILIA_ENCRYPTION_KEY’] config.encryption_personalization = ‘MyApp-2024’ # Optional customization end
# Generate a new encryption key key = Familia::Encryption.generate_key puts key # => “base64-encoded-32-byte-key”
Key Rotation:
The feature supports key versioning for seamless key rotation:
# Step 1: Add new key while keeping old key Familia.configure do |config| config.encryption_key = new_key config.legacy_encryption_keys = { ‘v1’ => old_key } end
# Step 2: Objects decrypt with old key, encrypt with new key vault.secret_key = “new-secret” # Encrypted with new key vault.save
# Step 3: After all data is re-encrypted, remove legacy 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
user.credit_card_number.expose do |cc_number|
# Process payment without storing plaintext
payment_gateway.charge(cc_number, amount)
end
# 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 cached per field/record combination
- XChaCha20-Poly1305 is ~2x faster than AES-256-GCM
- Memory allocation increases due to ciphertext expansion
- Consider batching operations for high-throughput scenarios
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: ClassMethods
Instance Method Summary collapse
-
#clear_encrypted_fields! ⇒ void
Clear all encrypted field values from memory.
-
#encrypted_data? ⇒ Boolean
Check if this instance has any encrypted fields with values.
-
#encrypted_fields_cleared? ⇒ Boolean
Check if all encrypted fields have been cleared from memory.
-
#encrypted_fields_status ⇒ Hash
Get encryption status for all encrypted fields.
-
#re_encrypt_fields! ⇒ Boolean
Re-encrypt all encrypted fields with current encryption settings.
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.
357 358 359 360 361 362 363 364 |
# File 'lib/familia/features/encrypted_fields.rb', line 357 def clear_encrypted_fields! self.class.encrypted_fields.each do |field_name| field_value = instance_variable_get("@#{field_name}") if field_value.respond_to?(:clear!) field_value.clear! end end end |
#encrypted_data? ⇒ Boolean
Check if this instance has any encrypted fields with values
TODO: Missing test coverage
337 338 339 340 341 342 |
# File 'lib/familia/features/encrypted_fields.rb', line 337 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
370 371 372 373 374 375 |
# File 'lib/familia/features/encrypted_fields.rb', line 370 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_status ⇒ Hash
Get encryption status for all encrypted fields
Returns a hash showing the encryption status of each encrypted field, useful for debugging and monitoring.
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/familia/features/encrypted_fields.rb', line 418 def encrypted_fields_status self.class.encrypted_fields.each_with_object({}) do |field_name, status| field_value = instance_variable_get("@#{field_name}") if field_value.nil? status[field_name] = { encrypted: false, value: nil } elsif field_value.respond_to?(:cleared?) && field_value.cleared? status[field_name] = { encrypted: true, cleared: true } elsif field_value.respond_to?(:concealed?) && field_value.concealed? status[field_name] = { encrypted: true, algorithm: "unknown", cleared: false } else status[field_name] = { 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.
389 390 391 392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/familia/features/encrypted_fields.rb', line 389 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 |