familia 2.0.0.pre5 → 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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +8 -5
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +4 -3
  5. data/docs/wiki/API-Reference.md +95 -18
  6. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  7. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  8. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  9. data/docs/wiki/Feature-System-Guide.md +600 -0
  10. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  11. data/docs/wiki/Field-System-Guide.md +784 -0
  12. data/docs/wiki/Home.md +72 -15
  13. data/docs/wiki/Implementation-Guide.md +126 -33
  14. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  15. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  16. data/docs/wiki/Security-Model.md +65 -25
  17. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  18. data/lib/familia/base.rb +1 -1
  19. data/lib/familia/data_type/types/counter.rb +38 -0
  20. data/lib/familia/data_type/types/hashkey.rb +18 -0
  21. data/lib/familia/data_type/types/lock.rb +43 -0
  22. data/lib/familia/data_type/types/string.rb +9 -2
  23. data/lib/familia/data_type.rb +2 -2
  24. data/lib/familia/encryption/encrypted_data.rb +137 -0
  25. data/lib/familia/encryption/manager.rb +21 -4
  26. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  27. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  28. data/lib/familia/encryption.rb +1 -1
  29. data/lib/familia/errors.rb +17 -3
  30. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  31. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  32. data/lib/familia/features/expiration.rb +1 -1
  33. data/lib/familia/features/quantization.rb +1 -1
  34. data/lib/familia/features/safe_dump.rb +1 -1
  35. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  36. data/lib/familia/features/transient_fields.rb +1 -1
  37. data/lib/familia/field_type.rb +5 -2
  38. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  39. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  40. data/lib/familia/horreum/core/serialization.rb +535 -0
  41. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  42. data/lib/familia/horreum/core.rb +21 -0
  43. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  44. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +44 -28
  45. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  46. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  47. data/lib/familia/horreum.rb +17 -17
  48. data/lib/familia/version.rb +1 -1
  49. data/lib/familia.rb +1 -1
  50. data/try/core/create_method_try.rb +240 -0
  51. data/try/core/database_consistency_try.rb +299 -0
  52. data/try/core/errors_try.rb +25 -4
  53. data/try/core/familia_try.rb +1 -1
  54. data/try/core/persistence_operations_try.rb +297 -0
  55. data/try/data_types/counter_try.rb +93 -0
  56. data/try/data_types/lock_try.rb +133 -0
  57. data/try/debugging/debug_aad_process.rb +82 -0
  58. data/try/debugging/debug_concealed_internal.rb +59 -0
  59. data/try/debugging/debug_concealed_reveal.rb +61 -0
  60. data/try/debugging/debug_context_aad.rb +68 -0
  61. data/try/debugging/debug_context_simple.rb +80 -0
  62. data/try/debugging/debug_cross_context.rb +62 -0
  63. data/try/debugging/debug_database_load.rb +64 -0
  64. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  65. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  66. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  67. data/try/debugging/debug_field_decrypt.rb +74 -0
  68. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  69. data/try/debugging/debug_load_path.rb +66 -0
  70. data/try/debugging/debug_method_definition.rb +46 -0
  71. data/try/debugging/debug_method_resolution.rb +41 -0
  72. data/try/debugging/debug_minimal.rb +24 -0
  73. data/try/debugging/debug_provider.rb +68 -0
  74. data/try/debugging/debug_secure_behavior.rb +73 -0
  75. data/try/debugging/debug_string_class.rb +46 -0
  76. data/try/debugging/debug_test.rb +46 -0
  77. data/try/debugging/debug_test_design.rb +80 -0
  78. data/try/encryption/encryption_core_try.rb +3 -3
  79. data/try/features/encrypted_fields_core_try.rb +19 -11
  80. data/try/features/encrypted_fields_integration_try.rb +66 -70
  81. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  82. data/try/features/encrypted_fields_security_try.rb +151 -144
  83. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  84. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  85. data/try/features/encryption_fields/context_isolation_try.rb +29 -8
  86. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  87. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  88. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  89. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  90. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  91. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  92. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  93. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  94. data/try/features/feature_dependencies_try.rb +3 -3
  95. data/try/features/transient_fields_core_try.rb +1 -1
  96. data/try/features/transient_fields_integration_try.rb +1 -1
  97. data/try/helpers/test_helpers.rb +25 -0
  98. data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
  99. data/try/horreum/initialization_try.rb +1 -1
  100. data/try/horreum/relations_try.rb +1 -1
  101. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  102. data/try/horreum/serialization_try.rb +39 -4
  103. data/try/models/customer_safe_dump_try.rb +1 -1
  104. data/try/models/customer_try.rb +1 -1
  105. metadata +51 -10
  106. data/TEST_COVERAGE.md +0 -40
  107. data/lib/familia/horreum/serialization.rb +0 -473
@@ -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
@@ -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
- super(name, **options.merge(on_conflict: :raise))
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
- encrypted = value.nil? ? nil : field_type.encrypt_value(self, value)
22
- instance_variable_set(:"@#{field_name}", encrypted)
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
- encrypted = instance_variable_get(:"@#{field_name}")
35
- encrypted.nil? ? nil : field_type.decrypt_value(self, encrypted)
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
- encrypted = field_type.encrypt_value(self, val)
99
+ # Set via the setter method to get proper ConcealedString wrapping
54
100
  send(:"#{method_name}=", val) if method_name
55
101
 
56
- ret = hset(field_name, encrypted)
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
- # ## Persistence-Dependent Behavior
156
+ # ## Consistent AAD Behavior
100
157
  #
101
- # AAD is only generated for records that exist in the database (`record.exists?`).
102
- # This creates an important behavioral distinction:
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
- # **Before Save (record.exists? == false):**
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
- # - Full cryptographic binding to database state
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**: Even without aad_fields, encrypted values
122
- # are bound to their specific record identifier after persistence.
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 for unsaved records
191
+ # @return [String, nil] AAD string for encryption, or nil if no identifier
139
192
  #
140
193
  def build_aad(record)
141
- return nil unless record.exists?
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, just use identifier
145
- record.identifier
204
+ # When no AAD fields specified, use class:field:identifier
205
+ base_components.join(':')
146
206
  else
147
- # Include specified field values in AAD
148
- values = @aad_fields.map { |field| record.send(field) }
149
- Digest::SHA256.hexdigest([record.identifier, *values].compact.join(':'))
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
@@ -8,7 +8,7 @@ module Familia
8
8
  @default_expiration = nil
9
9
 
10
10
  def self.included(base)
11
- Familia.ld "[#{base}] Loaded #{self}"
11
+ Familia.trace :LOADED!, nil, self, caller(1..1) if Familia.debug?
12
12
  base.extend ClassMethods
13
13
 
14
14
  # Optionally define default_expiration in the class to make
@@ -3,7 +3,7 @@
3
3
  module Familia::Features
4
4
  module Quantization
5
5
  def self.included(base)
6
- Familia.ld "[#{base}] Loaded #{self}"
6
+ Familia.trace :included, base, self, caller(1..1) if Familia.debug?
7
7
  base.extend ClassMethods
8
8
  end
9
9
 
@@ -54,7 +54,7 @@ module Familia::Features
54
54
  @safe_dump_field_map = {}
55
55
 
56
56
  def self.included(base)
57
- Familia.ld "[#{self}] Enabled in #{base}"
57
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
58
58
  base.extend ClassMethods
59
59
 
60
60
  # Optionally define safe_dump_fields in the class to make
@@ -141,7 +141,7 @@ class RedactedString
141
141
  def inspect = to_s
142
142
  def cleared? = @cleared
143
143
 
144
- # Returns true when it's literally the same object, otherwsie false.
144
+ # Returns true when it's literally the same object, otherwise false.
145
145
  # This prevents timing attacks where an attacker could potentially
146
146
  # infer information about the secret value through comparison timing
147
147
  def ==(other)
@@ -12,7 +12,7 @@ module Familia
12
12
  #
13
13
  module TransientFields
14
14
  def self.included(base)
15
- Familia.ld "[#{base}] Loaded #{self}"
15
+ Familia.trace :included, base, self, caller(1..1) if Familia.debug?
16
16
  base.extend ClassMethods
17
17
  end
18
18
 
@@ -27,7 +27,7 @@ module Familia
27
27
  # end
28
28
  #
29
29
  class FieldType
30
- attr_reader :name, :options, :method_name, :fast_method_name, :on_conflict
30
+ attr_reader :name, :options, :method_name, :fast_method_name, :on_conflict, :loggable
31
31
 
32
32
  # Initialize a new field type
33
33
  #
@@ -38,9 +38,11 @@ module Familia
38
38
  # (defaults to "#{name}!"). If false, no fast method is created
39
39
  # @param on_conflict [Symbol] Conflict resolution strategy when method
40
40
  # already exists (:raise, :skip, :warn, :overwrite)
41
+ # @param loggable [Boolean] Whether this field should be included in
42
+ # serialization and logging operations (default: true)
41
43
  # @param options [Hash] Additional options for the field type
42
44
  #
43
- def initialize(name, as: name, fast_method: :"#{name}!", on_conflict: :raise, **options)
45
+ def initialize(name, as: name, fast_method: :"#{name}!", on_conflict: :raise, loggable: true, **options)
44
46
  @name = name.to_sym
45
47
  @method_name = as == false ? nil : as.to_sym
46
48
  @fast_method_name = fast_method == false ? nil : fast_method&.to_sym
@@ -51,6 +53,7 @@ module Familia
51
53
  end
52
54
 
53
55
  @on_conflict = on_conflict
56
+ @loggable = loggable
54
57
  @options = options
55
58
  end
56
59
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Familia
4
4
  class Horreum
5
- # Familia::Horreum::Connection
6
- #
5
+ # Connection: Valkey connection management for Horreum instances
6
+ # Provides both instance and class-level connection methods
7
7
  module Connection
8
8
  attr_reader :uri
9
9
 
@@ -69,11 +69,5 @@ module Familia
69
69
  block_result
70
70
  end
71
71
  end
72
-
73
- # include for instance methods after it's loaded. Note that Horreum::Utils
74
- # are also included and at one time also has a uri method. This connection
75
- # module is also extended for the class level methods. It will require some
76
- # disambiguation at some point.
77
- include Familia::Horreum::Connection
78
72
  end
79
73
  end
@@ -37,7 +37,7 @@ module Familia
37
37
  # @note The default behavior maintains backward compatibility by treating empty hashes
38
38
  # as non-existent. Use `check_size: false` for pure key existence checking.
39
39
  def exists?(check_size: true)
40
- key_exists = self.class.dbclient.exists?(dbkey)
40
+ key_exists = self.class.exists?(identifier)
41
41
  return key_exists unless check_size
42
42
 
43
43
  key_exists && !size.zero?
@@ -106,6 +106,19 @@ module Familia
106
106
  dbclient.hset dbkey, field, value
107
107
  end
108
108
 
109
+ # Sets field in the hash stored at key to value, only if field does not yet exist.
110
+ # If key does not exist, a new key holding a hash is created. If field already exists,
111
+ # this operation has no effect.
112
+ #
113
+ # @param field [String] The field to set in the hash
114
+ # @param value [String] The value to set for the field
115
+ # @return [Integer] 1 if the field is a new field in the hash and the value was set,
116
+ # 0 if the field already exists in the hash and no operation was performed
117
+ def hsetnx(field, value)
118
+ Familia.trace :HSETNX, dbclient, field, caller(1..1) if Familia.debug?
119
+ dbclient.hsetnx dbkey, field, value
120
+ end
121
+
109
122
  def hmset(hsh = {})
110
123
  hsh ||= to_h
111
124
  Familia.trace :HMSET, dbclient, hsh, caller(1..1) if Familia.debug?
@@ -165,7 +178,5 @@ module Familia
165
178
  end
166
179
  alias clear delete!
167
180
  end
168
-
169
- include DatabaseCommands # these become Familia::Horreum instance methods
170
181
  end
171
182
  end