familia 2.0.0.pre4 → 2.0.0.pre6

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.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +11 -8
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +19 -3
  7. data/README.md +36 -157
  8. data/docs/overview.md +359 -0
  9. data/docs/wiki/API-Reference.md +347 -0
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +600 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +106 -0
  17. data/docs/wiki/Implementation-Guide.md +276 -0
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  20. data/docs/wiki/Security-Model.md +183 -0
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/lib/familia/base.rb +18 -27
  23. data/lib/familia/connection.rb +6 -5
  24. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  25. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  26. data/lib/familia/data_type/types/counter.rb +38 -0
  27. data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
  28. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  31. data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
  32. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  33. data/lib/familia/{datatype.rb → data_type.rb} +12 -14
  34. data/lib/familia/encryption/encrypted_data.rb +137 -0
  35. data/lib/familia/encryption/manager.rb +119 -0
  36. data/lib/familia/encryption/provider.rb +49 -0
  37. data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
  38. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  39. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -0
  40. data/lib/familia/encryption/registry.rb +50 -0
  41. data/lib/familia/encryption.rb +178 -0
  42. data/lib/familia/encryption_request_cache.rb +68 -0
  43. data/lib/familia/errors.rb +17 -3
  44. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  45. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -0
  46. data/lib/familia/features/encrypted_fields.rb +28 -0
  47. data/lib/familia/features/expiration.rb +107 -77
  48. data/lib/familia/features/quantization.rb +5 -9
  49. data/lib/familia/features/relatable_objects.rb +2 -4
  50. data/lib/familia/features/safe_dump.rb +14 -17
  51. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  52. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  53. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  54. data/lib/familia/features/transient_fields.rb +47 -0
  55. data/lib/familia/features.rb +40 -24
  56. data/lib/familia/field_type.rb +273 -0
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
  58. data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
  63. data/lib/familia/horreum/subclass/definition.rb +469 -0
  64. data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +30 -22
  67. data/lib/familia/logging.rb +14 -14
  68. data/lib/familia/settings.rb +39 -3
  69. data/lib/familia/utils.rb +45 -0
  70. data/lib/familia/version.rb +1 -1
  71. data/lib/familia.rb +3 -2
  72. data/try/core/base_enhancements_try.rb +115 -0
  73. data/try/core/connection_try.rb +0 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -5
  77. data/try/core/familia_extended_try.rb +3 -4
  78. data/try/core/familia_try.rb +1 -2
  79. data/try/core/persistence_operations_try.rb +297 -0
  80. data/try/core/pools_try.rb +2 -2
  81. data/try/core/secure_identifier_try.rb +0 -1
  82. data/try/core/settings_try.rb +0 -1
  83. data/try/core/utils_try.rb +0 -1
  84. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  85. data/try/data_types/counter_try.rb +93 -0
  86. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  87. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  88. data/try/{datatypes → data_types}/list_try.rb +1 -2
  89. data/try/data_types/lock_try.rb +133 -0
  90. data/try/{datatypes → data_types}/set_try.rb +1 -2
  91. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  92. data/try/{datatypes → data_types}/string_try.rb +1 -2
  93. data/try/debugging/README.md +32 -0
  94. data/try/debugging/cache_behavior_tracer.rb +91 -0
  95. data/try/debugging/debug_aad_process.rb +82 -0
  96. data/try/debugging/debug_concealed_internal.rb +59 -0
  97. data/try/debugging/debug_concealed_reveal.rb +61 -0
  98. data/try/debugging/debug_context_aad.rb +68 -0
  99. data/try/debugging/debug_context_simple.rb +80 -0
  100. data/try/debugging/debug_cross_context.rb +62 -0
  101. data/try/debugging/debug_database_load.rb +64 -0
  102. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  103. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  104. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  105. data/try/debugging/debug_field_decrypt.rb +74 -0
  106. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  107. data/try/debugging/debug_load_path.rb +66 -0
  108. data/try/debugging/debug_method_definition.rb +46 -0
  109. data/try/debugging/debug_method_resolution.rb +41 -0
  110. data/try/debugging/debug_minimal.rb +24 -0
  111. data/try/debugging/debug_provider.rb +68 -0
  112. data/try/debugging/debug_secure_behavior.rb +73 -0
  113. data/try/debugging/debug_string_class.rb +46 -0
  114. data/try/debugging/debug_test.rb +46 -0
  115. data/try/debugging/debug_test_design.rb +80 -0
  116. data/try/debugging/encryption_method_tracer.rb +138 -0
  117. data/try/debugging/provider_diagnostics.rb +110 -0
  118. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  119. data/try/edge_cases/json_serialization_try.rb +0 -1
  120. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  121. data/try/encryption/config_persistence_try.rb +192 -0
  122. data/try/encryption/encryption_core_try.rb +328 -0
  123. data/try/encryption/instance_variable_scope_try.rb +31 -0
  124. data/try/encryption/module_loading_try.rb +28 -0
  125. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  126. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  127. data/try/encryption/roundtrip_validation_try.rb +28 -0
  128. data/try/encryption/secure_memory_handling_try.rb +125 -0
  129. data/try/features/encrypted_fields_core_try.rb +125 -0
  130. data/try/features/encrypted_fields_integration_try.rb +216 -0
  131. data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
  132. data/try/features/encrypted_fields_security_try.rb +377 -0
  133. data/try/features/encryption_fields/aad_protection_try.rb +138 -0
  134. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  135. data/try/features/encryption_fields/context_isolation_try.rb +141 -0
  136. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  137. data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
  138. data/try/features/encryption_fields/fresh_key_try.rb +168 -0
  139. data/try/features/encryption_fields/key_rotation_try.rb +123 -0
  140. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  141. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  142. data/try/features/encryption_fields/nonce_uniqueness_try.rb +56 -0
  143. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  144. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  145. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  146. data/try/features/expiration_try.rb +0 -1
  147. data/try/features/feature_dependencies_try.rb +159 -0
  148. data/try/features/quantization_try.rb +0 -1
  149. data/try/features/real_feature_integration_try.rb +148 -0
  150. data/try/features/relatable_objects_try.rb +0 -1
  151. data/try/features/safe_dump_advanced_try.rb +0 -1
  152. data/try/features/safe_dump_try.rb +0 -1
  153. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  154. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  155. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  156. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  157. data/try/features/transient_fields_core_try.rb +181 -0
  158. data/try/features/transient_fields_integration_try.rb +260 -0
  159. data/try/helpers/test_helpers.rb +67 -0
  160. data/try/horreum/base_try.rb +157 -3
  161. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  162. data/try/horreum/field_categories_try.rb +118 -0
  163. data/try/horreum/field_definition_try.rb +96 -0
  164. data/try/horreum/initialization_try.rb +1 -2
  165. data/try/horreum/relations_try.rb +1 -2
  166. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  167. data/try/horreum/serialization_try.rb +41 -7
  168. data/try/memory/memory_basic_test.rb +73 -0
  169. data/try/memory/memory_detailed_test.rb +121 -0
  170. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  171. data/try/memory/memory_search_for_string.rb +83 -0
  172. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  173. data/try/models/customer_safe_dump_try.rb +1 -2
  174. data/try/models/customer_try.rb +1 -2
  175. data/try/models/datatype_base_try.rb +1 -2
  176. data/try/models/familia_object_try.rb +0 -1
  177. metadata +131 -23
  178. data/lib/familia/horreum/serialization.rb +0 -445
@@ -0,0 +1,68 @@
1
+ # lib/familia/encryption_request_cache.rb
2
+ #
3
+ # Request-scoped caching for encryption keys (if needed for performance)
4
+ # This should ONLY be enabled if performance testing shows it's necessary
5
+ #
6
+ # Usage in Rack middleware:
7
+ # class ClearEncryptionCacheMiddleware
8
+ # def call(env)
9
+ # Familia::Encryption.clear_request_cache!
10
+ # @app.call(env)
11
+ # ensure
12
+ # Familia::Encryption.clear_request_cache!
13
+ # end
14
+ # end
15
+
16
+ module Familia
17
+ module Encryption
18
+ class << self
19
+ # Enable request-scoped caching (opt-in for performance)
20
+ def with_request_cache
21
+ Thread.current[:familia_request_cache_enabled] = true
22
+ Thread.current[:familia_request_cache] = {}
23
+ yield
24
+ ensure
25
+ clear_request_cache!
26
+ end
27
+
28
+ # Clear all cached keys and disable caching
29
+ def clear_request_cache!
30
+ if (cache = Thread.current[:familia_request_cache])
31
+ cache.each_value { |key| secure_wipe(key) }
32
+ cache.clear
33
+ end
34
+ Thread.current[:familia_request_cache_enabled] = false
35
+ Thread.current[:familia_request_cache] = nil
36
+ end
37
+
38
+ private
39
+
40
+ # Modified derive_key that uses request cache when enabled
41
+ def derive_key_with_optional_cache(context, version: nil)
42
+ version ||= current_key_version
43
+ master_key = get_master_key(version)
44
+
45
+ # Only use cache if explicitly enabled for this request
46
+ if Thread.current[:familia_request_cache_enabled]
47
+ cache = Thread.current[:familia_request_cache] ||= {}
48
+ cache_key = "#{version}:#{context}"
49
+
50
+ # Return cached key if available (within same request only)
51
+ if (cached = cache[cache_key])
52
+ return cached.dup
53
+ end
54
+
55
+ # Derive and cache for this request only
56
+ derived = perform_key_derivation(master_key, context)
57
+ cache[cache_key] = derived.dup
58
+ derived
59
+ else
60
+ # Default: no caching for maximum security
61
+ perform_key_derivation(master_key, context)
62
+ end
63
+ ensure
64
+ secure_wipe(master_key) if master_key
65
+ end
66
+ end
67
+ end
68
+ end
@@ -34,8 +34,8 @@ module Familia
34
34
  # Set Familia.connection_provider or use middleware to provide connections.
35
35
  class NoConnectionAvailable < Problem; end
36
36
 
37
- # Raised when attempting to refresh an object whose key doesn't exist in Redis
38
- class KeyNotFoundError < Problem
37
+ # Raised when attempting to refresh an object whose key doesn't exist in the database
38
+ class KeyNotFoundError < NonUniqueKey
39
39
  attr_reader :key
40
40
 
41
41
  def initialize(key)
@@ -44,7 +44,21 @@ module Familia
44
44
  end
45
45
 
46
46
  def message
47
- "Key not found in Redis: #{key}"
47
+ "Key not found: #{key}"
48
+ end
49
+ end
50
+
51
+ # Raised when attempting to create an object that already exists in the database
52
+ class RecordExistsError < NonUniqueKey
53
+ attr_reader :key
54
+
55
+ def initialize(key)
56
+ @key = key
57
+ super
58
+ end
59
+
60
+ def message
61
+ "Key already exists: #{key}"
48
62
  end
49
63
  end
50
64
  end
@@ -0,0 +1,295 @@
1
+ # lib/familia/features/encrypted_fields/concealed_string.rb
2
+
3
+ # ConcealedString
4
+ #
5
+ # A secure wrapper for encrypted field values that prevents accidental
6
+ # plaintext leakage through serialization, logging, or debugging.
7
+ #
8
+ # Unlike RedactedString (which wraps plaintext), ConcealedString wraps
9
+ # encrypted data and provides controlled decryption through the .reveal API.
10
+ #
11
+ # Security Model:
12
+ # - Contains encrypted JSON data, never plaintext
13
+ # - Requires explicit .reveal { } for decryption and plaintext access
14
+ # - ALL serialization methods return '[CONCEALED]' to prevent leakage
15
+ # - Maintains encryption context for proper AAD handling
16
+ # - Thread-safe and supports concurrent access
17
+ #
18
+ # Key Security Features:
19
+ # 1. Universal Serialization Safety - ALL to_* methods protected
20
+ # 2. Debugging Safety - inspect, logging, console output shows [CONCEALED]
21
+ # 3. Exception Safety - never leaks plaintext in error messages
22
+ # 4. Future-proof - any new serialization method automatically safe
23
+ # 5. Memory Clearing - best-effort encrypted data clearing
24
+ #
25
+ # Critical Design Principles:
26
+ # - Secure by default - no auto-decryption anywhere
27
+ # - Explicit decryption - .reveal required for plaintext access
28
+ # - Comprehensive protection - covers ALL serialization paths
29
+ # - Auditable access - easy to grep for .reveal usage
30
+ #
31
+ # Example Usage:
32
+ # user = User.new
33
+ # user.secret_data = "sensitive info" # Encrypts and wraps
34
+ # user.secret_data # Returns ConcealedString
35
+ # user.secret_data.reveal { |plain| ... } # Explicit decryption
36
+ # user.to_h # Safe - contains [CONCEALED]
37
+ # user.to_json # Safe - contains [CONCEALED]
38
+ #
39
+ class ConcealedString
40
+ # Create a concealed string wrapper
41
+ #
42
+ # @param encrypted_data [String] The encrypted JSON data
43
+ # @param record [Familia::Horreum] The record instance for context
44
+ # @param field_type [EncryptedFieldType] The field type for decryption
45
+ #
46
+ def initialize(encrypted_data, record, field_type)
47
+ @encrypted_data = encrypted_data.freeze
48
+ @record = record
49
+ @field_type = field_type
50
+ @cleared = false
51
+
52
+ # Parse and validate the encrypted data structure
53
+ if @encrypted_data
54
+ begin
55
+ @encrypted_data_obj = Familia::Encryption::EncryptedData.from_json(@encrypted_data)
56
+ # Validate that the encrypted data is decryptable (algorithm supported, etc.)
57
+ @encrypted_data_obj.validate_decryptable!
58
+ rescue Familia::EncryptionError => e
59
+ raise Familia::EncryptionError, e.message
60
+ rescue StandardError => e
61
+ raise Familia::EncryptionError, "Invalid encrypted data: #{e.message}"
62
+ end
63
+ end
64
+
65
+ ObjectSpace.define_finalizer(self, self.class.finalizer_proc(@encrypted_data))
66
+ end
67
+
68
+ # Primary API: reveal the decrypted plaintext in a controlled block
69
+ #
70
+ # This is the ONLY way to access plaintext from encrypted fields.
71
+ # The plaintext is decrypted fresh each time using the current
72
+ # record state and AAD context.
73
+ #
74
+ # Security Warning: Avoid operations inside the block that create
75
+ # uncontrolled copies of the plaintext (dup, interpolation, etc.)
76
+ #
77
+ # @yield [String] The decrypted plaintext value
78
+ # @return [Object] The return value of the block
79
+ #
80
+ # Example:
81
+ # user.api_token.reveal do |token|
82
+ # HTTP.post('/api', headers: { 'X-Token' => token })
83
+ # end
84
+ #
85
+ def reveal
86
+ raise ArgumentError, 'Block required for reveal' unless block_given?
87
+ raise SecurityError, 'Encrypted data already cleared' if cleared?
88
+ raise SecurityError, 'No encrypted data to reveal' if @encrypted_data.nil?
89
+
90
+ # Decrypt using current record context and AAD
91
+ plaintext = @field_type.decrypt_value(@record, @encrypted_data)
92
+ yield plaintext
93
+ end
94
+
95
+ # Validate that this ConcealedString belongs to the given record context
96
+ #
97
+ # This prevents cross-context attacks where encrypted data is moved between
98
+ # different records or field contexts. While moving ConcealedString objects
99
+ # manually is not a normal use case, this provides defense in depth.
100
+ #
101
+ # @param expected_record [Familia::Horreum] The record that should own this data
102
+ # @param expected_field_name [Symbol] The field name that should own this data
103
+ # @return [Boolean] true if contexts match, false otherwise
104
+ #
105
+ def belongs_to_context?(expected_record, expected_field_name)
106
+ return false if @record.nil? || @field_type.nil?
107
+
108
+ @record.class.name == expected_record.class.name &&
109
+ @record.identifier == expected_record.identifier &&
110
+ @field_type.instance_variable_get(:@name) == expected_field_name
111
+ end
112
+
113
+ # Clear the encrypted data from memory
114
+ #
115
+ # Safe to call multiple times. This provides best-effort memory
116
+ # clearing within Ruby's limitations.
117
+ #
118
+ def clear!
119
+ return if @cleared
120
+
121
+ @encrypted_data = nil
122
+ @record = nil
123
+ @field_type = nil
124
+ @cleared = true
125
+ freeze
126
+ end
127
+
128
+ # Check if the encrypted data has been cleared
129
+ #
130
+ # @return [Boolean] true if cleared, false otherwise
131
+ #
132
+ def cleared?
133
+ @cleared
134
+ end
135
+
136
+ def empty?
137
+ @encrypted_data.to_s.empty?
138
+ end
139
+
140
+ # Returns true when it's literally the same object, otherwise false.
141
+ # This prevents timing attacks where an attacker could potentially
142
+ # infer information about the secret value through comparison timing
143
+ def ==(other)
144
+ object_id.equal?(other.object_id) # same object
145
+ end
146
+ alias eql? ==
147
+
148
+ # Access the encrypted data for database storage
149
+ #
150
+ # This method is used internally by the field type system
151
+ # for persisting the encrypted data to the database.
152
+ #
153
+ # @return [String, nil] The encrypted JSON data
154
+ #
155
+ def encrypted_value
156
+ @encrypted_data
157
+ end
158
+
159
+ # Prevent accidental exposure through string conversion and serialization
160
+ #
161
+ # Ruby has two string conversion methods with different purposes:
162
+ # - to_s: explicit conversion (obj.to_s, string interpolation "#{obj}")
163
+ # - to_str: implicit coercion (File.read(obj), "prefix" + obj)
164
+ #
165
+ # We implement to_s for safe logging/debugging but deliberately omit to_str
166
+ # to prevent encrypted data from being used where strings are expected.
167
+ #
168
+ def to_s
169
+ '[CONCEALED]'
170
+ end
171
+
172
+
173
+ # String methods that should return safe concealed values
174
+ def upcase
175
+ '[CONCEALED]'
176
+ end
177
+
178
+ def downcase
179
+ '[CONCEALED]'
180
+ end
181
+
182
+ def length
183
+ 11 # Fixed concealed length to match '[CONCEALED]' length
184
+ end
185
+
186
+ def size
187
+ length
188
+ end
189
+
190
+ def present?
191
+ true # Always return true since encrypted data exists
192
+ end
193
+
194
+ def blank?
195
+ false # Never blank if encrypted data exists
196
+ end
197
+
198
+ # String concatenation operations return concealed result
199
+ def +(other)
200
+ '[CONCEALED]'
201
+ end
202
+
203
+ def concat(other)
204
+ '[CONCEALED]'
205
+ end
206
+
207
+ # Handle coercion for concatenation like "string" + concealed
208
+ def coerce(other)
209
+ if other.is_a?(String)
210
+ ['[CONCEALED]', '[CONCEALED]']
211
+ else
212
+ [other, '[CONCEALED]']
213
+ end
214
+ end
215
+
216
+ # String pattern matching methods
217
+ def strip
218
+ '[CONCEALED]'
219
+ end
220
+
221
+ def gsub(*args)
222
+ '[CONCEALED]'
223
+ end
224
+
225
+ def include?(substring)
226
+ false # Never reveal substring presence
227
+ end
228
+
229
+ # Enumerable methods for safety
230
+ def map
231
+ yield '[CONCEALED]' if block_given?
232
+ ['[CONCEALED]']
233
+ end
234
+
235
+ def each
236
+ yield '[CONCEALED]' if block_given?
237
+ self
238
+ end
239
+
240
+ # Safe representation for debugging and console output
241
+ def inspect
242
+ '[CONCEALED]'
243
+ end
244
+
245
+ # Hash/Array serialization safety
246
+ def to_h
247
+ '[CONCEALED]'
248
+ end
249
+
250
+ def to_a
251
+ ['[CONCEALED]']
252
+ end
253
+
254
+ # Consistent hash to prevent timing attacks
255
+ def hash
256
+ ConcealedString.hash
257
+ end
258
+
259
+ # Pattern matching safety (Ruby 3.0+)
260
+ def deconstruct
261
+ ['[CONCEALED]']
262
+ end
263
+
264
+ def deconstruct_keys(keys)
265
+ { concealed: true }
266
+ end
267
+
268
+ # Prevent exposure in JSON serialization
269
+ def to_json(*args)
270
+ '"[CONCEALED]"'
271
+ end
272
+
273
+ # Prevent exposure in Rails serialization (as_json -> to_json)
274
+ def as_json(*args)
275
+ '[CONCEALED]'
276
+ end
277
+
278
+ private
279
+
280
+ # Check if a string looks like encrypted JSON data
281
+ def encrypted_json?(data)
282
+ Familia::Encryption::EncryptedData.valid?(data)
283
+ end
284
+
285
+ # Finalizer to attempt memory cleanup
286
+ def self.finalizer_proc(encrypted_data)
287
+ proc do |id|
288
+ # Best effort cleanup - Ruby doesn't guarantee memory security
289
+ # Only clear if not frozen to avoid FrozenError
290
+ if encrypted_data&.respond_to?(:clear) && !encrypted_data.frozen?
291
+ encrypted_data.clear
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,221 @@
1
+ # lib/familia/field_types/encrypted_field_type.rb
2
+
3
+ require_relative '../../field_type'
4
+ require_relative 'concealed_string'
5
+
6
+ module Familia
7
+ class EncryptedFieldType < FieldType
8
+ attr_reader :aad_fields
9
+
10
+ def initialize(name, aad_fields: [], **options)
11
+ # Encrypted fields are not loggable by default for security
12
+ super(name, **options.merge(on_conflict: :raise, loggable: false))
13
+ @aad_fields = Array(aad_fields).freeze
14
+ end
15
+
16
+ def define_setter(klass)
17
+ field_name = @name
18
+ method_name = @method_name
19
+ field_type = self
20
+
21
+ handle_method_conflict(klass, :"#{method_name}=") do
22
+ klass.define_method :"#{method_name}=" do |value|
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
41
+ end
42
+ end
43
+ end
44
+
45
+ def define_getter(klass)
46
+ field_name = @name
47
+ method_name = @method_name
48
+ field_type = self
49
+
50
+ handle_method_conflict(klass, method_name) do
51
+ klass.define_method method_name do
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
82
+ end
83
+ end
84
+ end
85
+
86
+ def define_fast_writer(klass)
87
+ # Encrypted fields override base fast writer for security
88
+ return unless @fast_method_name&.to_s&.end_with?('!')
89
+
90
+ field_name = @name
91
+ method_name = @method_name
92
+ fast_method_name = @fast_method_name
93
+ field_type = self
94
+
95
+ handle_method_conflict(klass, fast_method_name) do
96
+ klass.define_method fast_method_name do |val|
97
+ raise ArgumentError, "#{fast_method_name} requires a value" if val.nil?
98
+
99
+ # Set via the setter method to get proper ConcealedString wrapping
100
+ send(:"#{method_name}=", val) if method_name
101
+
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)
109
+ ret.zero? || ret.positive?
110
+ end
111
+ end
112
+ end
113
+
114
+ # Encrypt a value for the given record
115
+ def encrypt_value(record, value)
116
+ context = build_context(record)
117
+ additional_data = build_aad(record)
118
+
119
+ Familia::Encryption.encrypt(value, context: context, additional_data: additional_data)
120
+ end
121
+
122
+ # Decrypt a value for the given record
123
+ def decrypt_value(record, encrypted)
124
+ context = build_context(record)
125
+ additional_data = build_aad(record)
126
+
127
+ Familia::Encryption.decrypt(encrypted, context: context, additional_data: additional_data)
128
+ end
129
+
130
+ def persistent?
131
+ true
132
+ end
133
+
134
+ def category
135
+ :encrypted
136
+ end
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
+
143
+ private
144
+
145
+ # Build encryption context string
146
+ def build_context(record)
147
+ "#{record.class.name}:#{@name}:#{record.identifier}"
148
+ end
149
+
150
+ # Build Additional Authenticated Data (AAD) for authenticated encryption
151
+ #
152
+ # AAD provides cryptographic binding between encrypted field values and their
153
+ # containing record context. This prevents attackers from moving encrypted
154
+ # values between different records or field contexts, even with database access.
155
+ #
156
+ # ## Consistent AAD Behavior
157
+ #
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.
161
+ #
162
+ # **All Records (both new and persisted):**
163
+ # - AAD = record.identifier (no aad_fields) or SHA256(identifier:field1:field2:...)
164
+ # - Consistent cryptographic binding to record identity
165
+ # - Moving encrypted values between records/contexts will fail decryption
166
+ #
167
+ # ## Security Implications
168
+ #
169
+ # This design prevents several attack vectors:
170
+ #
171
+ # 1. **Field Value Swapping**: With aad_fields specified, encrypted values
172
+ # become bound to other field values. Changing owner_id breaks decryption.
173
+ #
174
+ # 2. **Cross-Record Migration**: Encrypted values are bound to their specific
175
+ # record identifier, preventing cross-record value movement.
176
+ #
177
+ # 3. **Temporal Consistency**: Re-encrypting the same plaintext after
178
+ # field changes produces different ciphertext due to AAD changes.
179
+ #
180
+ # ## Usage Patterns
181
+ #
182
+ # ```ruby
183
+ # # No AAD fields - basic record binding
184
+ # encrypted_field :secret_value
185
+ #
186
+ # # With AAD fields - multi-field binding
187
+ # encrypted_field :content, aad_fields: [:owner_id, :doc_type]
188
+ # ```
189
+ #
190
+ # @param record [Familia::Horreum] The record instance containing this field
191
+ # @return [String, nil] AAD string for encryption, or nil if no identifier
192
+ #
193
+ def build_aad(record)
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]
202
+
203
+ if @aad_fields.empty?
204
+ # When no AAD fields specified, use class:field:identifier
205
+ base_components.join(':')
206
+ else
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
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,28 @@
1
+ # lib/familia/features/encrypted_fields.rb
2
+
3
+ require_relative 'encrypted_fields/encrypted_field_type'
4
+
5
+ module Familia
6
+ module Features
7
+ module EncryptedFields
8
+ def self.included(base)
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ module ClassMethods
13
+ # Define an encrypted field
14
+ # @param name [Symbol] Field name
15
+ # @param aad_fields [Array<Symbol>] Optional fields to include in AAD
16
+ # @param kwargs [Hash] Additional field options
17
+ def encrypted_field(name, aad_fields: [], **)
18
+ require_relative 'encrypted_fields/encrypted_field_type'
19
+
20
+ field_type = EncryptedFieldType.new(name, aad_fields: aad_fields, **)
21
+ register_field_type(field_type)
22
+ end
23
+ end
24
+
25
+ Familia::Base.add_feature self, :encrypted_fields
26
+ end
27
+ end
28
+ end