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.
- checksums.yaml +4 -4
- data/CLAUDE.md +8 -5
- data/Gemfile +1 -1
- 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 +600 -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 +72 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/lib/familia/base.rb +1 -1
- 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 +2 -2
- 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 +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/expiration.rb +1 -1
- data/lib/familia/features/quantization.rb +1 -1
- 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 +1 -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} +44 -28
- 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/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/encryption/encryption_core_try.rb +3 -3
- 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 +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +29 -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/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +25 -0
- data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +1 -1
- 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
- metadata +51 -10
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/horreum/serialization.rb +0 -473
data/lib/familia/errors.rb
CHANGED
@@ -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
|
38
|
-
class KeyNotFoundError <
|
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
|
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
|
-
|
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
|
@@ -8,7 +8,7 @@ module Familia
|
|
8
8
|
@default_expiration = nil
|
9
9
|
|
10
10
|
def self.included(base)
|
11
|
-
Familia.
|
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
|
@@ -54,7 +54,7 @@ module Familia::Features
|
|
54
54
|
@safe_dump_field_map = {}
|
55
55
|
|
56
56
|
def self.included(base)
|
57
|
-
Familia.
|
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,
|
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)
|
data/lib/familia/field_type.rb
CHANGED
@@ -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
|
-
#
|
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.
|
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
|