familia 2.0.0.pre5 → 2.0.0.pre7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -10
- data/Gemfile +2 -2
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +631 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +82 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +9 -6
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +6 -4
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
- data/try/features/encryption_fields/context_isolation_try.rb +30 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +26 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +2 -2
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +81 -12
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/lib/familia/horreum/serialization.rb +0 -473
- data/try/features/relatable_objects_try.rb +0 -220
@@ -1,13 +1,15 @@
|
|
1
1
|
# lib/familia/field_types/encrypted_field_type.rb
|
2
2
|
|
3
3
|
require_relative '../../field_type'
|
4
|
+
require_relative 'concealed_string'
|
4
5
|
|
5
6
|
module Familia
|
6
7
|
class EncryptedFieldType < FieldType
|
7
8
|
attr_reader :aad_fields
|
8
9
|
|
9
10
|
def initialize(name, aad_fields: [], **options)
|
10
|
-
|
11
|
+
# Encrypted fields are not loggable by default for security
|
12
|
+
super(name, **options.merge(on_conflict: :raise, loggable: false))
|
11
13
|
@aad_fields = Array(aad_fields).freeze
|
12
14
|
end
|
13
15
|
|
@@ -18,8 +20,24 @@ module Familia
|
|
18
20
|
|
19
21
|
handle_method_conflict(klass, :"#{method_name}=") do
|
20
22
|
klass.define_method :"#{method_name}=" do |value|
|
21
|
-
|
22
|
-
|
23
|
+
if value.nil?
|
24
|
+
instance_variable_set(:"@#{field_name}", nil)
|
25
|
+
elsif value.is_a?(::String) && value.empty?
|
26
|
+
# Handle empty strings - treat as nil for encrypted fields
|
27
|
+
instance_variable_set(:"@#{field_name}", nil)
|
28
|
+
elsif value.is_a?(ConcealedString)
|
29
|
+
# Already concealed, store as-is
|
30
|
+
instance_variable_set(:"@#{field_name}", value)
|
31
|
+
elsif field_type.encrypted_json?(value)
|
32
|
+
# Already encrypted JSON from database - wrap in ConcealedString without re-encrypting
|
33
|
+
concealed = ConcealedString.new(value, self, field_type)
|
34
|
+
instance_variable_set(:"@#{field_name}", concealed)
|
35
|
+
else
|
36
|
+
# Encrypt plaintext and wrap in ConcealedString
|
37
|
+
encrypted = field_type.encrypt_value(self, value)
|
38
|
+
concealed = ConcealedString.new(encrypted, self, field_type)
|
39
|
+
instance_variable_set(:"@#{field_name}", concealed)
|
40
|
+
end
|
23
41
|
end
|
24
42
|
end
|
25
43
|
end
|
@@ -31,8 +49,36 @@ module Familia
|
|
31
49
|
|
32
50
|
handle_method_conflict(klass, method_name) do
|
33
51
|
klass.define_method method_name do
|
34
|
-
|
35
|
-
|
52
|
+
# Return ConcealedString directly - no auto-decryption!
|
53
|
+
# Caller must use .reveal { } for plaintext access
|
54
|
+
concealed = instance_variable_get(:"@#{field_name}")
|
55
|
+
|
56
|
+
# Return nil directly if that's what was set
|
57
|
+
return nil if concealed.nil?
|
58
|
+
|
59
|
+
# If we have a raw string (from direct instance variable manipulation),
|
60
|
+
# wrap it in ConcealedString which will trigger validation
|
61
|
+
if concealed.kind_of?(::String) && !concealed.is_a?(ConcealedString)
|
62
|
+
# This happens when someone directly sets the instance variable
|
63
|
+
# (e.g., during tampering tests). Wrapping in ConcealedString
|
64
|
+
# will trigger validate_decryptable! and catch invalid algorithms
|
65
|
+
begin
|
66
|
+
concealed = ConcealedString.new(concealed, self, field_type)
|
67
|
+
instance_variable_set(:"@#{field_name}", concealed)
|
68
|
+
rescue Familia::EncryptionError => e
|
69
|
+
# Increment derivation counter for failed validation attempts (similar to decrypt failures)
|
70
|
+
Familia::Encryption.derivation_count.increment
|
71
|
+
raise e
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Context validation: detect cross-context attacks
|
76
|
+
# Only validate if we have a proper ConcealedString instance
|
77
|
+
if concealed.is_a?(ConcealedString) && !concealed.belongs_to_context?(self, field_name)
|
78
|
+
raise Familia::EncryptionError, "Context isolation violation: encrypted field '#{field_name}' does not belong to #{self.class.name}:#{self.identifier}"
|
79
|
+
end
|
80
|
+
|
81
|
+
concealed
|
36
82
|
end
|
37
83
|
end
|
38
84
|
end
|
@@ -50,10 +96,16 @@ module Familia
|
|
50
96
|
klass.define_method fast_method_name do |val|
|
51
97
|
raise ArgumentError, "#{fast_method_name} requires a value" if val.nil?
|
52
98
|
|
53
|
-
|
99
|
+
# Set via the setter method to get proper ConcealedString wrapping
|
54
100
|
send(:"#{method_name}=", val) if method_name
|
55
101
|
|
56
|
-
|
102
|
+
# Get the ConcealedString and extract encrypted data for storage
|
103
|
+
concealed = instance_variable_get(:"@#{field_name}")
|
104
|
+
encrypted_data = concealed&.encrypted_value
|
105
|
+
|
106
|
+
return false if encrypted_data.nil?
|
107
|
+
|
108
|
+
ret = hset(field_name, encrypted_data)
|
57
109
|
ret.zero? || ret.positive?
|
58
110
|
end
|
59
111
|
end
|
@@ -83,6 +135,11 @@ module Familia
|
|
83
135
|
:encrypted
|
84
136
|
end
|
85
137
|
|
138
|
+
# Check if a string looks like encrypted JSON data
|
139
|
+
def encrypted_json?(data)
|
140
|
+
Familia::Encryption::EncryptedData.valid?(data)
|
141
|
+
end
|
142
|
+
|
86
143
|
private
|
87
144
|
|
88
145
|
# Build encryption context string
|
@@ -96,19 +153,15 @@ module Familia
|
|
96
153
|
# containing record context. This prevents attackers from moving encrypted
|
97
154
|
# values between different records or field contexts, even with database access.
|
98
155
|
#
|
99
|
-
# ##
|
156
|
+
# ## Consistent AAD Behavior
|
100
157
|
#
|
101
|
-
# AAD is
|
102
|
-
# This
|
158
|
+
# AAD is now consistently generated based on the record's identifier, regardless
|
159
|
+
# of persistence state. This ensures that encrypted values remain decryptable
|
160
|
+
# after save/load cycles while still providing security benefits.
|
103
161
|
#
|
104
|
-
# **
|
105
|
-
# - AAD = nil
|
106
|
-
# - Encryption context = "ClassName:fieldname:identifier" only
|
107
|
-
# - Values can be encrypted/decrypted freely in memory
|
108
|
-
#
|
109
|
-
# **After Save (record.exists? == true):**
|
162
|
+
# **All Records (both new and persisted):**
|
110
163
|
# - AAD = record.identifier (no aad_fields) or SHA256(identifier:field1:field2:...)
|
111
|
-
# -
|
164
|
+
# - Consistent cryptographic binding to record identity
|
112
165
|
# - Moving encrypted values between records/contexts will fail decryption
|
113
166
|
#
|
114
167
|
# ## Security Implications
|
@@ -118,8 +171,8 @@ module Familia
|
|
118
171
|
# 1. **Field Value Swapping**: With aad_fields specified, encrypted values
|
119
172
|
# become bound to other field values. Changing owner_id breaks decryption.
|
120
173
|
#
|
121
|
-
# 2. **Cross-Record Migration**:
|
122
|
-
#
|
174
|
+
# 2. **Cross-Record Migration**: Encrypted values are bound to their specific
|
175
|
+
# record identifier, preventing cross-record value movement.
|
123
176
|
#
|
124
177
|
# 3. **Temporal Consistency**: Re-encrypting the same plaintext after
|
125
178
|
# field changes produces different ciphertext due to AAD changes.
|
@@ -135,18 +188,33 @@ module Familia
|
|
135
188
|
# ```
|
136
189
|
#
|
137
190
|
# @param record [Familia::Horreum] The record instance containing this field
|
138
|
-
# @return [String, nil] AAD string for encryption, or nil
|
191
|
+
# @return [String, nil] AAD string for encryption, or nil if no identifier
|
139
192
|
#
|
140
193
|
def build_aad(record)
|
141
|
-
|
194
|
+
# AAD provides consistent context-aware binding, regardless of persistence state
|
195
|
+
# This ensures save/load cycles work while maintaining context isolation
|
196
|
+
identifier = record.identifier
|
197
|
+
return nil if identifier.nil? || identifier.to_s.empty?
|
198
|
+
|
199
|
+
# Include class and field name in AAD for context isolation
|
200
|
+
# This prevents cross-class and cross-field value migration
|
201
|
+
base_components = [record.class.name, @name, identifier]
|
142
202
|
|
143
203
|
if @aad_fields.empty?
|
144
|
-
# When no AAD fields specified,
|
145
|
-
|
204
|
+
# When no AAD fields specified, use class:field:identifier
|
205
|
+
base_components.join(':')
|
146
206
|
else
|
147
|
-
#
|
148
|
-
|
149
|
-
|
207
|
+
# For unsaved records, don't enforce AAD fields since they can change
|
208
|
+
# For saved records, include field values for tamper protection
|
209
|
+
if record.exists?
|
210
|
+
# Include specified field values in AAD for persisted records
|
211
|
+
values = @aad_fields.map { |field| record.send(field) }
|
212
|
+
all_components = [*base_components, *values].compact
|
213
|
+
Digest::SHA256.hexdigest(all_components.join(':'))
|
214
|
+
else
|
215
|
+
# For unsaved records, only use class:field:identifier for context isolation
|
216
|
+
base_components.join(':')
|
217
|
+
end
|
150
218
|
end
|
151
219
|
end
|
152
220
|
end
|
@@ -4,22 +4,431 @@ require_relative 'encrypted_fields/encrypted_field_type'
|
|
4
4
|
|
5
5
|
module Familia
|
6
6
|
module Features
|
7
|
+
# EncryptedFields is a feature that provides transparent encryption and decryption
|
8
|
+
# of sensitive data stored in Redis/Valkey. It uses strong cryptographic algorithms
|
9
|
+
# with field-specific key derivation to protect data at rest while maintaining
|
10
|
+
# easy access patterns for authorized applications.
|
11
|
+
#
|
12
|
+
# This feature automatically encrypts field values before storage and decrypts
|
13
|
+
# them on access, providing seamless integration with existing code while
|
14
|
+
# ensuring sensitive data is never stored in plaintext.
|
15
|
+
#
|
16
|
+
# Supported Encryption Algorithms:
|
17
|
+
# - XChaCha20-Poly1305 (preferred, requires rbnacl gem)
|
18
|
+
# - AES-256-GCM (fallback, uses OpenSSL)
|
19
|
+
#
|
20
|
+
# Example:
|
21
|
+
#
|
22
|
+
# class Vault < Familia::Horreum
|
23
|
+
# feature :encrypted_fields
|
24
|
+
#
|
25
|
+
# field :name # Regular unencrypted field
|
26
|
+
# encrypted_field :secret_key # Encrypted storage
|
27
|
+
# encrypted_field :api_token # Another encrypted field
|
28
|
+
# encrypted_field :love_letter # Ultra-sensitive field
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# vault = Vault.new(
|
32
|
+
# name: "Production Vault",
|
33
|
+
# secret_key: "super-secret-key-123",
|
34
|
+
# api_token: "sk-1234567890abcdef",
|
35
|
+
# love_letter: "Dear Alice, I love you. -Bob"
|
36
|
+
# )
|
37
|
+
#
|
38
|
+
# vault.save
|
39
|
+
# # Only 'name' is stored in plaintext
|
40
|
+
# # secret_key, api_token, love_letter are encrypted
|
41
|
+
#
|
42
|
+
# # Access is transparent
|
43
|
+
# vault.secret_key # => "super-secret-key-123" (decrypted automatically)
|
44
|
+
# vault.api_token # => "sk-1234567890abcdef" (decrypted automatically)
|
45
|
+
#
|
46
|
+
# Security Features:
|
47
|
+
#
|
48
|
+
# Each encrypted field uses a unique encryption key derived from:
|
49
|
+
# - Master encryption key (from Familia.encryption_key)
|
50
|
+
# - Field name (cryptographic domain separation)
|
51
|
+
# - Record identifier (per-record key derivation)
|
52
|
+
# - Class name (per-class key derivation)
|
53
|
+
#
|
54
|
+
# This ensures that:
|
55
|
+
# - Swapping encrypted values between fields fails to decrypt
|
56
|
+
# - Each record has unique encryption keys
|
57
|
+
# - Different classes cannot decrypt each other's data
|
58
|
+
# - Field-level access control is cryptographically enforced
|
59
|
+
#
|
60
|
+
# Cryptographic Design:
|
61
|
+
#
|
62
|
+
# # XChaCha20-Poly1305 (preferred)
|
63
|
+
# - 256-bit keys (32 bytes)
|
64
|
+
# - 192-bit nonces (24 bytes) - extended nonce space
|
65
|
+
# - 128-bit authentication tags (16 bytes)
|
66
|
+
# - BLAKE2b key derivation with personalization
|
67
|
+
#
|
68
|
+
# # AES-256-GCM (fallback)
|
69
|
+
# - 256-bit keys (32 bytes)
|
70
|
+
# - 96-bit nonces (12 bytes) - standard GCM nonce
|
71
|
+
# - 128-bit authentication tags (16 bytes)
|
72
|
+
# - HKDF-SHA256 key derivation
|
73
|
+
#
|
74
|
+
# Ciphertext Format:
|
75
|
+
#
|
76
|
+
# Encrypted data is stored as JSON with algorithm-specific metadata:
|
77
|
+
#
|
78
|
+
# {
|
79
|
+
# "algorithm": "xchacha20poly1305",
|
80
|
+
# "nonce": "base64_encoded_nonce",
|
81
|
+
# "ciphertext": "base64_encoded_data",
|
82
|
+
# "auth_tag": "base64_encoded_tag",
|
83
|
+
# "key_version": "v1"
|
84
|
+
# }
|
85
|
+
#
|
86
|
+
# Additional Authenticated Data (AAD):
|
87
|
+
#
|
88
|
+
# For extra security, you can include other field values in the authentication:
|
89
|
+
#
|
90
|
+
# class SecureDocument < Familia::Horreum
|
91
|
+
# feature :encrypted_fields
|
92
|
+
#
|
93
|
+
# field :doc_id, :owner_id, :classification
|
94
|
+
# encrypted_field :content, aad_fields: [:doc_id, :owner_id, :classification]
|
95
|
+
# end
|
96
|
+
#
|
97
|
+
# # The content can only be decrypted if doc_id, owner_id, and classification
|
98
|
+
# # values match those used during encryption
|
99
|
+
#
|
100
|
+
# Passphrase Protection:
|
101
|
+
#
|
102
|
+
# For ultra-sensitive fields, require user passphrases for decryption:
|
103
|
+
#
|
104
|
+
# class PersonalVault < Familia::Horreum
|
105
|
+
# feature :encrypted_fields
|
106
|
+
#
|
107
|
+
# field :user_id
|
108
|
+
# encrypted_field :diary_entry # Ultra-sensitive
|
109
|
+
# encrypted_field :photos # Ultra-sensitive
|
110
|
+
# end
|
111
|
+
#
|
112
|
+
# vault = PersonalVault.new(user_id: 123, diary_entry: "Dear diary...")
|
113
|
+
# vault.save
|
114
|
+
#
|
115
|
+
# # Passphrase required for decryption
|
116
|
+
# diary = vault.diary_entry(passphrase_value: user_passphrase)
|
117
|
+
#
|
118
|
+
# Memory Safety:
|
119
|
+
#
|
120
|
+
# Encrypted fields return ConcealedString objects that provide memory protection:
|
121
|
+
#
|
122
|
+
# secret = vault.secret_key
|
123
|
+
# secret.class # => ConcealedString
|
124
|
+
# puts secret # => "[CONCEALED]" (automatic redaction)
|
125
|
+
# secret.inspect # => "[CONCEALED]" (automatic redaction)
|
126
|
+
#
|
127
|
+
# # Safe access pattern
|
128
|
+
# secret.expose do |value|
|
129
|
+
# # Use value directly without creating copies
|
130
|
+
# api_call(authorization: "Bearer #{value}")
|
131
|
+
# end
|
132
|
+
#
|
133
|
+
# # Direct access (use carefully)
|
134
|
+
# raw_value = secret.value # Returns actual decrypted string
|
135
|
+
#
|
136
|
+
# # Explicit cleanup
|
137
|
+
# secret.clear! # Best-effort memory wiping
|
138
|
+
#
|
139
|
+
# Error Handling:
|
140
|
+
#
|
141
|
+
# The feature provides specific error types for different failure modes:
|
142
|
+
#
|
143
|
+
# # Invalid ciphertext or tampering
|
144
|
+
# vault.secret_key # => Familia::EncryptionError: Authentication failed
|
145
|
+
#
|
146
|
+
# # Wrong passphrase
|
147
|
+
# vault.diary_entry(passphrase_value: "wrong")
|
148
|
+
# # => Familia::EncryptionError: Invalid passphrase
|
149
|
+
#
|
150
|
+
# # Missing encryption key
|
151
|
+
# Familia.encryption_key = nil
|
152
|
+
# vault.secret_key # => Familia::EncryptionError: No encryption key configured
|
153
|
+
#
|
154
|
+
# Configuration:
|
155
|
+
#
|
156
|
+
# # Set master encryption key (required)
|
157
|
+
# Familia.configure do |config|
|
158
|
+
# config.encryption_key = ENV['FAMILIA_ENCRYPTION_KEY']
|
159
|
+
# config.encryption_personalization = 'MyApp-2024' # Optional customization
|
160
|
+
# end
|
161
|
+
#
|
162
|
+
# # Generate a new encryption key
|
163
|
+
# key = Familia::Encryption.generate_key
|
164
|
+
# puts key # => "base64-encoded-32-byte-key"
|
165
|
+
#
|
166
|
+
# Key Rotation:
|
167
|
+
#
|
168
|
+
# The feature supports key versioning for seamless key rotation:
|
169
|
+
#
|
170
|
+
# # Step 1: Add new key while keeping old key
|
171
|
+
# Familia.configure do |config|
|
172
|
+
# config.encryption_key = new_key
|
173
|
+
# config.legacy_encryption_keys = { 'v1' => old_key }
|
174
|
+
# end
|
175
|
+
#
|
176
|
+
# # Step 2: Objects decrypt with old key, encrypt with new key
|
177
|
+
# vault.secret_key = "new-secret" # Encrypted with new key
|
178
|
+
# vault.save
|
179
|
+
#
|
180
|
+
# # Step 3: After all data is re-encrypted, remove legacy key
|
181
|
+
#
|
182
|
+
# Integration Patterns:
|
183
|
+
#
|
184
|
+
# # Rails application
|
185
|
+
# class User < ApplicationRecord
|
186
|
+
# include Familia::Horreum
|
187
|
+
# feature :encrypted_fields
|
188
|
+
#
|
189
|
+
# field :user_id
|
190
|
+
# encrypted_field :credit_card_number
|
191
|
+
# encrypted_field :ssn, aad_fields: [:user_id]
|
192
|
+
# end
|
193
|
+
#
|
194
|
+
# # API serialization (encrypted fields excluded by default)
|
195
|
+
# class UserSerializer
|
196
|
+
# def self.serialize(user)
|
197
|
+
# {
|
198
|
+
# id: user.user_id,
|
199
|
+
# created_at: user.created_at,
|
200
|
+
# # credit_card_number and ssn are NOT included
|
201
|
+
# }
|
202
|
+
# end
|
203
|
+
# end
|
204
|
+
#
|
205
|
+
# # Background job processing
|
206
|
+
# class PaymentProcessor
|
207
|
+
# def process_payment(user_id)
|
208
|
+
# user = User.find(user_id)
|
209
|
+
#
|
210
|
+
# # Access encrypted field safely
|
211
|
+
# user.credit_card_number.expose do |cc_number|
|
212
|
+
# # Process payment without storing plaintext
|
213
|
+
# payment_gateway.charge(cc_number, amount)
|
214
|
+
# end
|
215
|
+
#
|
216
|
+
# # Clear sensitive data from memory
|
217
|
+
# user.credit_card_number.clear!
|
218
|
+
# end
|
219
|
+
# end
|
220
|
+
#
|
221
|
+
# Performance Considerations:
|
222
|
+
#
|
223
|
+
# - Encryption/decryption adds ~1-5ms overhead per field
|
224
|
+
# - Key derivation is cached per field/record combination
|
225
|
+
# - XChaCha20-Poly1305 is ~2x faster than AES-256-GCM
|
226
|
+
# - Memory allocation increases due to ciphertext expansion
|
227
|
+
# - Consider batching operations for high-throughput scenarios
|
228
|
+
#
|
229
|
+
# Security Limitations:
|
230
|
+
#
|
231
|
+
# ⚠️ Important: Ruby provides NO memory safety guarantees:
|
232
|
+
# - No secure memory wiping (best-effort only)
|
233
|
+
# - Garbage collector may copy secrets
|
234
|
+
# - String operations create uncontrolled copies
|
235
|
+
# - Memory dumps may contain plaintext secrets
|
236
|
+
#
|
237
|
+
# For highly sensitive applications, consider:
|
238
|
+
# - External key management (HashiCorp Vault, AWS KMS)
|
239
|
+
# - Hardware Security Modules (HSMs)
|
240
|
+
# - Languages with secure memory handling
|
241
|
+
# - Dedicated cryptographic appliances
|
242
|
+
#
|
243
|
+
# Threat Model:
|
244
|
+
#
|
245
|
+
# ✅ Protected Against:
|
246
|
+
# - Database compromise (encrypted data only)
|
247
|
+
# - Field value swapping (field-specific keys)
|
248
|
+
# - Cross-record attacks (record-specific keys)
|
249
|
+
# - Tampering (authenticated encryption)
|
250
|
+
#
|
251
|
+
# ❌ Not Protected Against:
|
252
|
+
# - Master key compromise (all data compromised)
|
253
|
+
# - Application memory compromise (plaintext in RAM)
|
254
|
+
# - Side-channel attacks (timing, power analysis)
|
255
|
+
# - Insider threats with application access
|
256
|
+
#
|
7
257
|
module EncryptedFields
|
8
258
|
def self.included(base)
|
259
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
9
260
|
base.extend ClassMethods
|
261
|
+
|
262
|
+
# Initialize encrypted fields tracking
|
263
|
+
base.instance_variable_set(:@encrypted_fields, []) unless base.instance_variable_defined?(:@encrypted_fields)
|
10
264
|
end
|
11
265
|
|
12
266
|
module ClassMethods
|
13
|
-
# Define an encrypted field
|
267
|
+
# Define an encrypted field that transparently encrypts/decrypts values
|
268
|
+
#
|
269
|
+
# Encrypted fields are stored as JSON objects containing the encrypted
|
270
|
+
# ciphertext along with cryptographic metadata. Values are automatically
|
271
|
+
# encrypted on assignment and decrypted on access.
|
272
|
+
#
|
14
273
|
# @param name [Symbol] Field name
|
15
|
-
# @param aad_fields [Array<Symbol>]
|
274
|
+
# @param aad_fields [Array<Symbol>] Additional fields to include in authentication
|
16
275
|
# @param kwargs [Hash] Additional field options
|
17
|
-
|
276
|
+
#
|
277
|
+
# @example Basic encrypted field
|
278
|
+
# class Vault < Familia::Horreum
|
279
|
+
# feature :encrypted_fields
|
280
|
+
# encrypted_field :secret_key
|
281
|
+
# end
|
282
|
+
#
|
283
|
+
# @example Encrypted field with additional authentication
|
284
|
+
# class Document < Familia::Horreum
|
285
|
+
# feature :encrypted_fields
|
286
|
+
# field :doc_id, :owner_id
|
287
|
+
# encrypted_field :content, aad_fields: [:doc_id, :owner_id]
|
288
|
+
# end
|
289
|
+
#
|
290
|
+
def encrypted_field(name, aad_fields: [], **kwargs)
|
291
|
+
@encrypted_fields ||= []
|
292
|
+
@encrypted_fields << name unless @encrypted_fields.include?(name)
|
293
|
+
|
18
294
|
require_relative 'encrypted_fields/encrypted_field_type'
|
19
295
|
|
20
|
-
field_type = EncryptedFieldType.new(name, aad_fields: aad_fields, **)
|
296
|
+
field_type = EncryptedFieldType.new(name, aad_fields: aad_fields, **kwargs)
|
21
297
|
register_field_type(field_type)
|
22
298
|
end
|
299
|
+
|
300
|
+
# Returns list of encrypted field names defined on this class
|
301
|
+
#
|
302
|
+
# @return [Array<Symbol>] Array of encrypted field names
|
303
|
+
#
|
304
|
+
def encrypted_fields
|
305
|
+
@encrypted_fields || []
|
306
|
+
end
|
307
|
+
|
308
|
+
# Check if a field is encrypted
|
309
|
+
#
|
310
|
+
# @param field_name [Symbol] The field name to check
|
311
|
+
# @return [Boolean] true if field is encrypted, false otherwise
|
312
|
+
#
|
313
|
+
def encrypted_field?(field_name)
|
314
|
+
encrypted_fields.include?(field_name.to_sym)
|
315
|
+
end
|
316
|
+
|
317
|
+
# Get encryption algorithm information
|
318
|
+
#
|
319
|
+
# @return [Hash] Hash containing encryption algorithm details
|
320
|
+
#
|
321
|
+
def encryption_info
|
322
|
+
provider = Familia::Encryption.current_provider
|
323
|
+
{
|
324
|
+
algorithm: provider.algorithm_name,
|
325
|
+
key_size: provider.key_size,
|
326
|
+
nonce_size: provider.nonce_size,
|
327
|
+
tag_size: provider.tag_size
|
328
|
+
}
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
# Check if this instance has any encrypted fields with values
|
333
|
+
#
|
334
|
+
# @return [Boolean] true if any encrypted fields have values
|
335
|
+
#
|
336
|
+
# TODO: Missing test coverage
|
337
|
+
def encrypted_data?
|
338
|
+
self.class.encrypted_fields.any? do |field_name|
|
339
|
+
field_value = instance_variable_get("@#{field_name}")
|
340
|
+
!field_value.nil?
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
# Clear all encrypted field values from memory
|
345
|
+
#
|
346
|
+
# This method iterates through all encrypted fields and calls clear!
|
347
|
+
# on any ConcealedString instances. Use this for cleanup when the
|
348
|
+
# object is no longer needed.
|
349
|
+
#
|
350
|
+
# @return [void]
|
351
|
+
#
|
352
|
+
# @example Clear all secrets when done
|
353
|
+
# vault = Vault.new(secret_key: 'secret', api_token: 'token123')
|
354
|
+
# # ... use vault ...
|
355
|
+
# vault.clear_encrypted_fields!
|
356
|
+
#
|
357
|
+
def clear_encrypted_fields!
|
358
|
+
self.class.encrypted_fields.each do |field_name|
|
359
|
+
field_value = instance_variable_get("@#{field_name}")
|
360
|
+
if field_value.respond_to?(:clear!)
|
361
|
+
field_value.clear!
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
# Check if all encrypted fields have been cleared from memory
|
367
|
+
#
|
368
|
+
# @return [Boolean] true if all encrypted fields are cleared, false otherwise
|
369
|
+
#
|
370
|
+
def encrypted_fields_cleared?
|
371
|
+
self.class.encrypted_fields.all? do |field_name|
|
372
|
+
field_value = instance_variable_get("@#{field_name}")
|
373
|
+
field_value.nil? || (field_value.respond_to?(:cleared?) && field_value.cleared?)
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
# Re-encrypt all encrypted fields with current encryption settings
|
378
|
+
#
|
379
|
+
# This method is useful for key rotation or algorithm upgrades.
|
380
|
+
# It decrypts all encrypted fields and re-encrypts them with the
|
381
|
+
# current encryption configuration.
|
382
|
+
#
|
383
|
+
# @return [Boolean] true if re-encryption succeeded
|
384
|
+
#
|
385
|
+
# @example Re-encrypt after key rotation
|
386
|
+
# vault.re_encrypt_fields!
|
387
|
+
# vault.save
|
388
|
+
#
|
389
|
+
def re_encrypt_fields!
|
390
|
+
self.class.encrypted_fields.each do |field_name|
|
391
|
+
current_value = send(field_name)
|
392
|
+
next if current_value.nil?
|
393
|
+
|
394
|
+
# Force re-encryption by setting the value again
|
395
|
+
if current_value.respond_to?(:value)
|
396
|
+
send("#{field_name}=", current_value.value)
|
397
|
+
else
|
398
|
+
send("#{field_name}=", current_value)
|
399
|
+
end
|
400
|
+
end
|
401
|
+
true
|
402
|
+
end
|
403
|
+
|
404
|
+
# Get encryption status for all encrypted fields
|
405
|
+
#
|
406
|
+
# Returns a hash showing the encryption status of each encrypted field,
|
407
|
+
# useful for debugging and monitoring.
|
408
|
+
#
|
409
|
+
# @return [Hash] Hash with field names as keys and status information
|
410
|
+
#
|
411
|
+
# @example Check encryption status
|
412
|
+
# vault.encrypted_fields_status
|
413
|
+
# # => {
|
414
|
+
# # secret_key: { encrypted: true, algorithm: "xchacha20poly1305", cleared: false },
|
415
|
+
# # api_token: { encrypted: true, algorithm: "aes-256-gcm", cleared: true }
|
416
|
+
# # }
|
417
|
+
#
|
418
|
+
def encrypted_fields_status
|
419
|
+
self.class.encrypted_fields.each_with_object({}) do |field_name, status|
|
420
|
+
field_value = instance_variable_get("@#{field_name}")
|
421
|
+
|
422
|
+
if field_value.nil?
|
423
|
+
status[field_name] = { encrypted: false, value: nil }
|
424
|
+
elsif field_value.respond_to?(:cleared?) && field_value.cleared?
|
425
|
+
status[field_name] = { encrypted: true, cleared: true }
|
426
|
+
elsif field_value.respond_to?(:concealed?) && field_value.concealed?
|
427
|
+
status[field_name] = { encrypted: true, algorithm: "unknown", cleared: false }
|
428
|
+
else
|
429
|
+
status[field_name] = { encrypted: false, value: "[CONCEALED]" }
|
430
|
+
end
|
431
|
+
end
|
23
432
|
end
|
24
433
|
|
25
434
|
Familia::Base.add_feature self, :encrypted_fields
|