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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.rubocop_todo.yml +17 -17
- data/CLAUDE.md +11 -8
- data/Gemfile +5 -1
- data/Gemfile.lock +19 -3
- data/README.md +36 -157
- data/docs/overview.md +359 -0
- data/docs/wiki/API-Reference.md +347 -0
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
- 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 +106 -0
- data/docs/wiki/Implementation-Guide.md +276 -0
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +183 -0
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/lib/familia/base.rb +18 -27
- data/lib/familia/connection.rb +6 -5
- data/lib/familia/{datatype → data_type}/commands.rb +2 -5
- data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
- data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
- data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
- data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
- data/lib/familia/{datatype.rb → data_type.rb} +12 -14
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +119 -0
- data/lib/familia/encryption/provider.rb +49 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -0
- data/lib/familia/encryption/registry.rb +50 -0
- data/lib/familia/encryption.rb +178 -0
- data/lib/familia/encryption_request_cache.rb +68 -0
- 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 +221 -0
- data/lib/familia/features/encrypted_fields.rb +28 -0
- data/lib/familia/features/expiration.rb +107 -77
- data/lib/familia/features/quantization.rb +5 -9
- data/lib/familia/features/relatable_objects.rb +2 -4
- data/lib/familia/features/safe_dump.rb +14 -17
- data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
- data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
- data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
- data/lib/familia/features/transient_fields.rb +47 -0
- data/lib/familia/features.rb +40 -24
- data/lib/familia/field_type.rb +273 -0
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
- data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
- data/lib/familia/horreum/subclass/definition.rb +469 -0
- data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +30 -22
- data/lib/familia/logging.rb +14 -14
- data/lib/familia/settings.rb +39 -3
- data/lib/familia/utils.rb +45 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +3 -2
- data/try/core/base_enhancements_try.rb +115 -0
- data/try/core/connection_try.rb +0 -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 -5
- data/try/core/familia_extended_try.rb +3 -4
- data/try/core/familia_try.rb +1 -2
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/core/pools_try.rb +2 -2
- data/try/core/secure_identifier_try.rb +0 -1
- data/try/core/settings_try.rb +0 -1
- data/try/core/utils_try.rb +0 -1
- data/try/{datatypes → data_types}/boolean_try.rb +1 -2
- data/try/data_types/counter_try.rb +93 -0
- data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
- data/try/{datatypes → data_types}/hash_try.rb +1 -2
- data/try/{datatypes → data_types}/list_try.rb +1 -2
- data/try/data_types/lock_try.rb +133 -0
- data/try/{datatypes → data_types}/set_try.rb +1 -2
- data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
- data/try/{datatypes → data_types}/string_try.rb +1 -2
- data/try/debugging/README.md +32 -0
- data/try/debugging/cache_behavior_tracer.rb +91 -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/debugging/encryption_method_tracer.rb +138 -0
- data/try/debugging/provider_diagnostics.rb +110 -0
- data/try/edge_cases/hash_symbolization_try.rb +0 -1
- data/try/edge_cases/json_serialization_try.rb +0 -1
- data/try/edge_cases/reserved_keywords_try.rb +42 -11
- data/try/encryption/config_persistence_try.rb +192 -0
- data/try/encryption/encryption_core_try.rb +328 -0
- data/try/encryption/instance_variable_scope_try.rb +31 -0
- data/try/encryption/module_loading_try.rb +28 -0
- data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
- data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
- data/try/encryption/roundtrip_validation_try.rb +28 -0
- data/try/encryption/secure_memory_handling_try.rb +125 -0
- data/try/features/encrypted_fields_core_try.rb +125 -0
- data/try/features/encrypted_fields_integration_try.rb +216 -0
- data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
- data/try/features/encrypted_fields_security_try.rb +377 -0
- data/try/features/encryption_fields/aad_protection_try.rb +138 -0
- data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +141 -0
- data/try/features/encryption_fields/error_conditions_try.rb +116 -0
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
- data/try/features/encryption_fields/fresh_key_try.rb +168 -0
- data/try/features/encryption_fields/key_rotation_try.rb +123 -0
- data/try/features/encryption_fields/memory_security_try.rb +37 -0
- data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +56 -0
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +199 -0
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/expiration_try.rb +0 -1
- data/try/features/feature_dependencies_try.rb +159 -0
- data/try/features/quantization_try.rb +0 -1
- data/try/features/real_feature_integration_try.rb +148 -0
- data/try/features/relatable_objects_try.rb +0 -1
- data/try/features/safe_dump_advanced_try.rb +0 -1
- data/try/features/safe_dump_try.rb +0 -1
- data/try/features/transient_fields/redacted_string_try.rb +248 -0
- data/try/features/transient_fields/refresh_reset_try.rb +164 -0
- data/try/features/transient_fields/simple_refresh_test.rb +50 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
- data/try/features/transient_fields_core_try.rb +181 -0
- data/try/features/transient_fields_integration_try.rb +260 -0
- data/try/helpers/test_helpers.rb +67 -0
- data/try/horreum/base_try.rb +157 -3
- data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
- data/try/horreum/field_categories_try.rb +118 -0
- data/try/horreum/field_definition_try.rb +96 -0
- data/try/horreum/initialization_try.rb +1 -2
- data/try/horreum/relations_try.rb +1 -2
- data/try/horreum/serialization_persistent_fields_try.rb +165 -0
- data/try/horreum/serialization_try.rb +41 -7
- data/try/memory/memory_basic_test.rb +73 -0
- data/try/memory/memory_detailed_test.rb +121 -0
- data/try/memory/memory_docker_ruby_dump.sh +80 -0
- data/try/memory/memory_search_for_string.rb +83 -0
- data/try/memory/test_actual_redactedstring_protection.rb +38 -0
- data/try/models/customer_safe_dump_try.rb +1 -2
- data/try/models/customer_try.rb +1 -2
- data/try/models/datatype_base_try.rb +1 -2
- data/try/models/familia_object_try.rb +0 -1
- metadata +131 -23
- data/lib/familia/horreum/serialization.rb +0 -445
@@ -0,0 +1,219 @@
|
|
1
|
+
# try/features/encrypted_fields_no_cache_security_try.rb
|
2
|
+
#
|
3
|
+
# Security tests for the no-cache encryption strategy
|
4
|
+
# These tests verify that we maintain security properties by NOT caching derived keys
|
5
|
+
|
6
|
+
require_relative '../helpers/test_helpers'
|
7
|
+
|
8
|
+
test_keys = {
|
9
|
+
v1: Base64.strict_encode64('a' * 32),
|
10
|
+
v2: Base64.strict_encode64('b' * 32)
|
11
|
+
}
|
12
|
+
Familia.config.encryption_keys = test_keys
|
13
|
+
Familia.config.current_key_version = :v1
|
14
|
+
|
15
|
+
## No persistent key cache exists
|
16
|
+
## Verify that we don't maintain a key cache at all
|
17
|
+
Thread.current[:familia_key_cache]
|
18
|
+
#=> nil
|
19
|
+
|
20
|
+
## Each encryption gets fresh key derivation
|
21
|
+
class NoCacheTestModel1 < Familia::Horreum
|
22
|
+
feature :encrypted_fields
|
23
|
+
identifier_field :user_id
|
24
|
+
field :user_id
|
25
|
+
encrypted_field :sensitive_data
|
26
|
+
end
|
27
|
+
|
28
|
+
@model = NoCacheTestModel1.new(user_id: 'test1')
|
29
|
+
@model.sensitive_data = 'secret-value'
|
30
|
+
#=> 'secret-value'
|
31
|
+
|
32
|
+
## No cache should be created
|
33
|
+
Thread.current[:familia_key_cache]
|
34
|
+
#=> nil
|
35
|
+
|
36
|
+
## Reading the value also doesn't create cache
|
37
|
+
@retrieved = @model.sensitive_data
|
38
|
+
@retrieved.reveal do |decrypted_value|
|
39
|
+
decrypted_value
|
40
|
+
end
|
41
|
+
#=> 'secret-value'
|
42
|
+
|
43
|
+
## repaired test
|
44
|
+
Thread.current[:familia_key_cache]
|
45
|
+
#=> nil
|
46
|
+
|
47
|
+
## Multiple fields don't share state
|
48
|
+
class NoCacheTestModel2 < Familia::Horreum
|
49
|
+
feature :encrypted_fields
|
50
|
+
identifier_field :user_id
|
51
|
+
field :user_id
|
52
|
+
encrypted_field :field_a
|
53
|
+
encrypted_field :field_b
|
54
|
+
encrypted_field :field_c
|
55
|
+
end
|
56
|
+
|
57
|
+
@model2 = NoCacheTestModel2.new(user_id: 'test2')
|
58
|
+
@model2.field_a = 'value-a'
|
59
|
+
@model2.field_b = 'value-b'
|
60
|
+
@model2.field_c = 'value-c'
|
61
|
+
#=> 'value-c'
|
62
|
+
|
63
|
+
## Still no cache after multiple operations
|
64
|
+
Thread.current[:familia_key_cache]
|
65
|
+
#=> nil
|
66
|
+
|
67
|
+
## All values can be retrieved correctly
|
68
|
+
@model2.field_a.reveal do |decrypted_value|
|
69
|
+
decrypted_value
|
70
|
+
end
|
71
|
+
#=> 'value-a'
|
72
|
+
|
73
|
+
## Field b retrieves correctly
|
74
|
+
@model2.field_b.reveal do |decrypted_value|
|
75
|
+
decrypted_value
|
76
|
+
end
|
77
|
+
#=> 'value-b'
|
78
|
+
|
79
|
+
## Field c retrieves correctly
|
80
|
+
@model2.field_c.reveal do |decrypted_value|
|
81
|
+
decrypted_value
|
82
|
+
end
|
83
|
+
#=> 'value-c'
|
84
|
+
|
85
|
+
## Still no cache
|
86
|
+
Thread.current[:familia_key_cache]
|
87
|
+
#=> nil
|
88
|
+
|
89
|
+
## Master keys are wiped after each operation
|
90
|
+
## This test verifies that master keys don't persist in memory
|
91
|
+
## We can't directly test memory wiping, but we verify the behavior
|
92
|
+
class NoCacheTestModel3 < Familia::Horreum
|
93
|
+
feature :encrypted_fields
|
94
|
+
identifier_field :user_id
|
95
|
+
field :user_id
|
96
|
+
encrypted_field :secret
|
97
|
+
end
|
98
|
+
|
99
|
+
# Create multiple instances with different data
|
100
|
+
@users = (1..10).map do |i|
|
101
|
+
user = NoCacheTestModel3.new(user_id: "user#{i}")
|
102
|
+
user.secret = "secret-#{i}"
|
103
|
+
user
|
104
|
+
end
|
105
|
+
|
106
|
+
# Verify all can decrypt correctly (proves fresh derivation each time)
|
107
|
+
@users.each_with_index do |user, i|
|
108
|
+
decrypted = user.secret
|
109
|
+
decrypted == "secret-#{i}"
|
110
|
+
end.all?
|
111
|
+
#=> true
|
112
|
+
|
113
|
+
## Still no cache after multiple operations
|
114
|
+
Thread.current[:familia_key_cache]
|
115
|
+
#=> nil
|
116
|
+
|
117
|
+
## Thread isolation (no shared state between threads)
|
118
|
+
class NoCacheTestModel4 < Familia::Horreum
|
119
|
+
feature :encrypted_fields
|
120
|
+
identifier_field :user_id
|
121
|
+
field :user_id
|
122
|
+
encrypted_field :thread_secret
|
123
|
+
end
|
124
|
+
|
125
|
+
@results = []
|
126
|
+
@threads = []
|
127
|
+
|
128
|
+
5.times do |i|
|
129
|
+
@threads << Thread.new do
|
130
|
+
# Each thread creates its own model
|
131
|
+
model = NoCacheTestModel4.new(user_id: "thread#{i}")
|
132
|
+
model.thread_secret = "thread-secret-#{i}"
|
133
|
+
|
134
|
+
# Verify no cache in this thread
|
135
|
+
cache_state = Thread.current[:familia_key_cache]
|
136
|
+
|
137
|
+
# Store results
|
138
|
+
@results << {
|
139
|
+
thread_id: i,
|
140
|
+
cache_is_nil: cache_state.nil?,
|
141
|
+
value_correct: model.thread_secret.reveal do |decrypted_value|
|
142
|
+
decrypted_value == "thread-secret-#{i}"
|
143
|
+
end
|
144
|
+
}
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
@threads.each(&:join)
|
149
|
+
@threads.size
|
150
|
+
#=> 5
|
151
|
+
|
152
|
+
## All threads should report no cache
|
153
|
+
@results.all? { |r| r[:cache_is_nil] }
|
154
|
+
#=> true
|
155
|
+
|
156
|
+
## All threads should have correct values
|
157
|
+
@results.all? {|r| r[:value_correct] }
|
158
|
+
#=> true
|
159
|
+
|
160
|
+
## Performance: Key derivation happens every time
|
161
|
+
## This test demonstrates that we prioritize security over performance
|
162
|
+
class NoCacheTestModel5 < Familia::Horreum
|
163
|
+
feature :encrypted_fields
|
164
|
+
identifier_field :user_id
|
165
|
+
field :user_id
|
166
|
+
encrypted_field :perf_field
|
167
|
+
end
|
168
|
+
|
169
|
+
@model5 = NoCacheTestModel5.new(user_id: 'perf-test')
|
170
|
+
@model5.perf_field = 'initial-value'
|
171
|
+
#=> 'initial-value'
|
172
|
+
|
173
|
+
## Multiple reads all trigger fresh key derivation
|
174
|
+
@read_results = 100.times.map do
|
175
|
+
value = @model5.perf_field.reveal do |decrypted_value|
|
176
|
+
decrypted_value
|
177
|
+
end
|
178
|
+
value == 'initial-value'
|
179
|
+
end
|
180
|
+
|
181
|
+
@read_results.all?
|
182
|
+
#=> true
|
183
|
+
|
184
|
+
## Still no cache after 100 operations
|
185
|
+
Thread.current[:familia_key_cache]
|
186
|
+
#=> nil
|
187
|
+
|
188
|
+
## Key rotation works without cache complications
|
189
|
+
Familia.config.current_key_version = :v2
|
190
|
+
|
191
|
+
class NoCacheTestModel6 < Familia::Horreum
|
192
|
+
feature :encrypted_fields
|
193
|
+
identifier_field :user_id
|
194
|
+
field :user_id
|
195
|
+
encrypted_field :rotated_field
|
196
|
+
end
|
197
|
+
|
198
|
+
# Encrypt with v2
|
199
|
+
@model6 = NoCacheTestModel6.new(user_id: 'rotation-test')
|
200
|
+
@model6.rotated_field = 'encrypted-with-v2'
|
201
|
+
|
202
|
+
# Still no cache with new key version
|
203
|
+
Thread.current[:familia_key_cache]
|
204
|
+
#=> nil
|
205
|
+
|
206
|
+
## Value is correctly encrypted/decrypted with v2
|
207
|
+
@model6.rotated_field.reveal do |decrypted_value|
|
208
|
+
decrypted_value
|
209
|
+
end
|
210
|
+
#=> 'encrypted-with-v2'
|
211
|
+
|
212
|
+
## Reset to v1 for other tests
|
213
|
+
Familia.config.current_key_version = :v1
|
214
|
+
#=> :v1
|
215
|
+
|
216
|
+
# Teardown
|
217
|
+
Thread.current[:familia_key_cache] = nil
|
218
|
+
Familia.config.encryption_keys = nil
|
219
|
+
Familia.config.current_key_version = nil
|
@@ -0,0 +1,377 @@
|
|
1
|
+
# try/features/encrypted_fields_security_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
# Define all test classes up front to avoid tryouts retry conflicts
|
7
|
+
|
8
|
+
class SecurityTestModel < Familia::Horreum
|
9
|
+
feature :encrypted_fields
|
10
|
+
identifier_field :user_id
|
11
|
+
field :user_id
|
12
|
+
encrypted_field :password # No AAD
|
13
|
+
encrypted_field :api_key # No AAD
|
14
|
+
encrypted_field :secret_data # No AAD
|
15
|
+
end
|
16
|
+
|
17
|
+
class SecurityTestModel2 < Familia::Horreum
|
18
|
+
feature :encrypted_fields
|
19
|
+
identifier_field :user_id
|
20
|
+
field :user_id
|
21
|
+
field :email
|
22
|
+
encrypted_field :api_key, aad_fields: [:email]
|
23
|
+
end
|
24
|
+
|
25
|
+
class SecurityTestModel3 < Familia::Horreum
|
26
|
+
feature :encrypted_fields
|
27
|
+
identifier_field :user_id
|
28
|
+
field :user_id
|
29
|
+
encrypted_field :password
|
30
|
+
end
|
31
|
+
|
32
|
+
class SecurityTestModel4 < Familia::Horreum
|
33
|
+
feature :encrypted_fields
|
34
|
+
identifier_field :user_id
|
35
|
+
field :user_id
|
36
|
+
encrypted_field :password
|
37
|
+
end
|
38
|
+
|
39
|
+
class SecurityTestModel5 < Familia::Horreum
|
40
|
+
feature :encrypted_fields
|
41
|
+
identifier_field :user_id
|
42
|
+
field :user_id
|
43
|
+
encrypted_field :password
|
44
|
+
end
|
45
|
+
|
46
|
+
class SecurityTestModel6 < Familia::Horreum
|
47
|
+
feature :encrypted_fields
|
48
|
+
identifier_field :user_id
|
49
|
+
field :user_id
|
50
|
+
encrypted_field :password
|
51
|
+
end
|
52
|
+
|
53
|
+
class SecurityTestModelNonceXChaCha < Familia::Horreum
|
54
|
+
feature :encrypted_fields
|
55
|
+
identifier_field :user_id
|
56
|
+
field :user_id
|
57
|
+
encrypted_field :password
|
58
|
+
end
|
59
|
+
|
60
|
+
class SecurityTestModelNonceAES < Familia::Horreum
|
61
|
+
feature :encrypted_fields
|
62
|
+
identifier_field :user_id
|
63
|
+
field :user_id
|
64
|
+
encrypted_field :password
|
65
|
+
end
|
66
|
+
|
67
|
+
class SecurityTestModel7 < Familia::Horreum
|
68
|
+
feature :encrypted_fields
|
69
|
+
identifier_field :user_id
|
70
|
+
field :user_id
|
71
|
+
encrypted_field :password
|
72
|
+
end
|
73
|
+
|
74
|
+
class SecurityTestModel8 < Familia::Horreum
|
75
|
+
feature :encrypted_fields
|
76
|
+
identifier_field :user_id
|
77
|
+
field :user_id
|
78
|
+
encrypted_field :password
|
79
|
+
end
|
80
|
+
|
81
|
+
class JsonTamperTestModel < Familia::Horreum
|
82
|
+
feature :encrypted_fields
|
83
|
+
identifier_field :userid
|
84
|
+
field :userid
|
85
|
+
encrypted_field :secret_data
|
86
|
+
end
|
87
|
+
|
88
|
+
class SecurityTestModel10 < Familia::Horreum
|
89
|
+
feature :encrypted_fields
|
90
|
+
identifier_field :user_id
|
91
|
+
field :user_id
|
92
|
+
encrypted_field :password
|
93
|
+
end
|
94
|
+
|
95
|
+
## Context isolation: Different field contexts use different encryption
|
96
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
97
|
+
Familia.config.encryption_keys = test_keys
|
98
|
+
Familia.config.current_key_version = :v1
|
99
|
+
|
100
|
+
user = SecurityTestModel.new(user_id: 'user1')
|
101
|
+
|
102
|
+
user.password = 'same-value'
|
103
|
+
user.api_key = 'same-value'
|
104
|
+
user.secret_data = 'same-value'
|
105
|
+
|
106
|
+
password_encrypted = user.instance_variable_get(:@password)
|
107
|
+
api_key_encrypted = user.instance_variable_get(:@api_key)
|
108
|
+
secret_data_encrypted = user.instance_variable_get(:@secret_data)
|
109
|
+
|
110
|
+
[password_encrypted != api_key_encrypted,
|
111
|
+
password_encrypted != secret_data_encrypted,
|
112
|
+
api_key_encrypted != secret_data_encrypted]
|
113
|
+
#=> [true, true, true]
|
114
|
+
|
115
|
+
## AAD Protection: Different users get different AAD
|
116
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
117
|
+
Familia.config.encryption_keys = test_keys
|
118
|
+
Familia.config.current_key_version = :v1
|
119
|
+
|
120
|
+
user1 = SecurityTestModel2.new(user_id: 'user1', email: 'user1@example.com')
|
121
|
+
user2 = SecurityTestModel2.new(user_id: 'user2', email: 'user2@example.com')
|
122
|
+
|
123
|
+
# Same value with different AAD should encrypt differently
|
124
|
+
user1.api_key = 'same-api-key-value'
|
125
|
+
user2.api_key = 'same-api-key-value'
|
126
|
+
|
127
|
+
user1_encrypted = user1.instance_variable_get(:@api_key)
|
128
|
+
user2_encrypted = user2.instance_variable_get(:@api_key)
|
129
|
+
|
130
|
+
user1_encrypted != user2_encrypted
|
131
|
+
#=> true
|
132
|
+
|
133
|
+
## Auth tag manipulation fails authentication
|
134
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
135
|
+
Familia.config.encryption_keys = test_keys
|
136
|
+
Familia.config.current_key_version = :v1
|
137
|
+
|
138
|
+
user = SecurityTestModel3.new(user_id: 'user1')
|
139
|
+
user.password = 'test-password'
|
140
|
+
encrypted = user.instance_variable_get(:@password)
|
141
|
+
|
142
|
+
# Tamper with auth tag
|
143
|
+
parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
|
144
|
+
original_auth_tag = parsed[:auth_tag]
|
145
|
+
tampered_auth_tag = original_auth_tag.dup
|
146
|
+
tampered_auth_tag[0] = tampered_auth_tag[0] == 'A' ? 'B' : 'A'
|
147
|
+
parsed[:auth_tag] = tampered_auth_tag
|
148
|
+
tampered_json = parsed.to_json
|
149
|
+
|
150
|
+
user.instance_variable_set(:@password, tampered_json)
|
151
|
+
begin
|
152
|
+
user.password.reveal { |plain| plain }
|
153
|
+
"should_not_reach_here"
|
154
|
+
rescue Familia::EncryptionError => e
|
155
|
+
e.message.include?("Decryption failed")
|
156
|
+
end
|
157
|
+
#=> true
|
158
|
+
|
159
|
+
## Ciphertext manipulation fails authentication
|
160
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
161
|
+
Familia.config.encryption_keys = test_keys
|
162
|
+
Familia.config.current_key_version = :v1
|
163
|
+
|
164
|
+
user = SecurityTestModel4.new(user_id: 'user1')
|
165
|
+
user.password = 'test-password'
|
166
|
+
encrypted = user.instance_variable_get(:@password)
|
167
|
+
|
168
|
+
# Tamper with ciphertext
|
169
|
+
parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
|
170
|
+
original_ciphertext = parsed[:ciphertext]
|
171
|
+
tampered_ciphertext = original_ciphertext.dup
|
172
|
+
tampered_ciphertext[0] = tampered_ciphertext[0] == 'A' ? 'B' : 'A'
|
173
|
+
parsed[:ciphertext] = tampered_ciphertext
|
174
|
+
tampered_json = parsed.to_json
|
175
|
+
|
176
|
+
user.instance_variable_set(:@password, tampered_json)
|
177
|
+
begin
|
178
|
+
user.password.reveal { |plain| plain }
|
179
|
+
"should_not_reach_here"
|
180
|
+
rescue Familia::EncryptionError => e
|
181
|
+
e.message.include?("Decryption failed")
|
182
|
+
end
|
183
|
+
#=> true
|
184
|
+
|
185
|
+
## Nonce manipulation detection
|
186
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
187
|
+
Familia.config.encryption_keys = test_keys
|
188
|
+
Familia.config.current_key_version = :v1
|
189
|
+
|
190
|
+
user = SecurityTestModel5.new(user_id: 'user1')
|
191
|
+
user.password = 'test-password'
|
192
|
+
encrypted = user.instance_variable_get(:@password)
|
193
|
+
|
194
|
+
# Tamper with nonce
|
195
|
+
parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
|
196
|
+
original_nonce = parsed[:nonce]
|
197
|
+
tampered_nonce = original_nonce.dup
|
198
|
+
tampered_nonce[0] = tampered_nonce[0] == 'A' ? 'B' : 'A'
|
199
|
+
parsed[:nonce] = tampered_nonce
|
200
|
+
tampered_json = parsed.to_json
|
201
|
+
|
202
|
+
user.instance_variable_set(:@password, tampered_json)
|
203
|
+
begin
|
204
|
+
user.password.reveal { |plain| plain }
|
205
|
+
"should_not_reach_here"
|
206
|
+
rescue Familia::EncryptionError => e
|
207
|
+
e.message.include?("Decryption failed")
|
208
|
+
end
|
209
|
+
#=> true
|
210
|
+
|
211
|
+
## Key isolation: Wrong key version prevents decryption
|
212
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
213
|
+
Familia.config.encryption_keys = test_keys
|
214
|
+
Familia.config.current_key_version = :v1
|
215
|
+
|
216
|
+
user = SecurityTestModel6.new(user_id: 'user1')
|
217
|
+
user.password = 'key-isolation-test'
|
218
|
+
encrypted_with_v1 = user.instance_variable_get(:@password)
|
219
|
+
|
220
|
+
|
221
|
+
# Parse and change key version to non-existent version
|
222
|
+
parsed = JSON.parse(encrypted_with_v1.encrypted_value, symbolize_names: true)
|
223
|
+
parsed[:key_version] = 'v999'
|
224
|
+
modified_json = parsed.to_json
|
225
|
+
|
226
|
+
user.instance_variable_set(:@password, modified_json)
|
227
|
+
begin
|
228
|
+
user.password.reveal { |plain| plain }
|
229
|
+
"should_not_reach_here"
|
230
|
+
rescue Familia::EncryptionError => e
|
231
|
+
e.message.include?("No key for version")
|
232
|
+
end
|
233
|
+
#=> true
|
234
|
+
|
235
|
+
## Nonce manipulation fails authentication - XChaCha20Poly1305 (24-byte nonces)
|
236
|
+
|
237
|
+
user = SecurityTestModelNonceXChaCha.new(user_id: 'user1')
|
238
|
+
user.password = 'nonce-test-xchacha'
|
239
|
+
encrypted_with_nonce = user.instance_variable_get(:@password)
|
240
|
+
|
241
|
+
# Parse and modify nonce (XChaCha20Poly1305 uses 24-byte nonces)
|
242
|
+
parsed = JSON.parse(encrypted_with_nonce.encrypted_value, symbolize_names: true)
|
243
|
+
original_nonce = parsed[:nonce]
|
244
|
+
# Create a different valid base64 nonce for XChaCha20Poly1305 (24 bytes)
|
245
|
+
different_nonce = Base64.strict_encode64('x' * 24)
|
246
|
+
parsed[:nonce] = different_nonce
|
247
|
+
modified_json = parsed.to_json
|
248
|
+
|
249
|
+
user.instance_variable_set(:@password, modified_json)
|
250
|
+
begin
|
251
|
+
user.password.reveal { |plain| plain }
|
252
|
+
"should_not_reach_here"
|
253
|
+
rescue Familia::EncryptionError => e
|
254
|
+
e.message.include?("Decryption failed")
|
255
|
+
end
|
256
|
+
#=> true
|
257
|
+
|
258
|
+
## Nonce manipulation fails authentication - AES-GCM (12-byte nonces)
|
259
|
+
|
260
|
+
user_aes = SecurityTestModelNonceAES.new(user_id: 'user2')
|
261
|
+
# Force AES-GCM encryption for this test
|
262
|
+
encrypted_aes = Familia::Encryption.encrypt_with('aes-256-gcm', 'nonce-test-aes',
|
263
|
+
context: 'SecurityTestModelNonceAES:user2:password')
|
264
|
+
user_aes.instance_variable_set(:@password, encrypted_aes)
|
265
|
+
|
266
|
+
# Parse and modify nonce (AES-GCM uses 12-byte nonces)
|
267
|
+
parsed_aes = JSON.parse(encrypted_aes, symbolize_names: true)
|
268
|
+
original_nonce_aes = parsed_aes[:nonce]
|
269
|
+
# Create a different valid base64 nonce for AES-GCM (12 bytes)
|
270
|
+
different_nonce_aes = Base64.strict_encode64('y' * 12)
|
271
|
+
parsed_aes[:nonce] = different_nonce_aes
|
272
|
+
modified_json_aes = parsed_aes.to_json
|
273
|
+
|
274
|
+
user_aes.instance_variable_set(:@password, modified_json_aes)
|
275
|
+
begin
|
276
|
+
user_aes.password.reveal { |plain| plain }
|
277
|
+
"should_not_reach_here"
|
278
|
+
rescue Familia::EncryptionError => e
|
279
|
+
e.message.include?("Decryption failed")
|
280
|
+
end
|
281
|
+
#=> true
|
282
|
+
|
283
|
+
## Key cache isolation between different contexts
|
284
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
285
|
+
Familia.config.encryption_keys = test_keys
|
286
|
+
Familia.config.current_key_version = :v1
|
287
|
+
|
288
|
+
user = SecurityTestModel7.new(user_id: 'cache-user')
|
289
|
+
user.password = 'cache-test'
|
290
|
+
|
291
|
+
# Use different encryption context
|
292
|
+
other_user = SecurityTestModel8.new(user_id: 'cache-user')
|
293
|
+
other_user.password = 'cache-test'
|
294
|
+
|
295
|
+
user_encrypted = user.instance_variable_get(:@password)
|
296
|
+
other_encrypted = other_user.instance_variable_get(:@password)
|
297
|
+
|
298
|
+
# Different classes should have different key caches
|
299
|
+
user_encrypted != other_encrypted
|
300
|
+
#=> true
|
301
|
+
|
302
|
+
## Cross-user encrypted data should not decrypt
|
303
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
304
|
+
Familia.config.encryption_keys = test_keys
|
305
|
+
Familia.config.current_key_version = :v1
|
306
|
+
|
307
|
+
user1 = SecurityTestModel7.new(user_id: 'user1')
|
308
|
+
user2 = SecurityTestModel7.new(user_id: 'user2')
|
309
|
+
|
310
|
+
user1.password = 'user1-password'
|
311
|
+
user1_encrypted = user1.instance_variable_get(:@password)
|
312
|
+
|
313
|
+
# Try to use user1's encrypted data with user2's context
|
314
|
+
user2.instance_variable_set(:@password, user1_encrypted.encrypted_value)
|
315
|
+
|
316
|
+
# This should fail due to different AAD contexts (user1 vs user2)
|
317
|
+
begin
|
318
|
+
user2.password.reveal { |plain| plain }
|
319
|
+
false
|
320
|
+
rescue Familia::EncryptionError => e
|
321
|
+
e.message.include?("Decryption failed")
|
322
|
+
end
|
323
|
+
#=> true
|
324
|
+
|
325
|
+
## Thread-local key cache independence
|
326
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
327
|
+
Familia.config.encryption_keys = test_keys
|
328
|
+
Familia.config.current_key_version = :v1
|
329
|
+
|
330
|
+
user = SecurityTestModel8.new(user_id: 'thread-user')
|
331
|
+
user.password = 'thread-test'
|
332
|
+
main_encrypted = user.instance_variable_get(:@password)
|
333
|
+
|
334
|
+
thread_encrypted = nil
|
335
|
+
Thread.new do
|
336
|
+
thread_user = SecurityTestModel8.new(user_id: 'thread-user')
|
337
|
+
thread_user.password = 'thread-test'
|
338
|
+
thread_encrypted = thread_user.instance_variable_get(:@password)
|
339
|
+
end.join
|
340
|
+
|
341
|
+
# Different threads should have independent key caches
|
342
|
+
# And different nonces mean different encrypted values even for same plaintext
|
343
|
+
main_encrypted != thread_encrypted
|
344
|
+
#=> true
|
345
|
+
|
346
|
+
## JSON structure tampering detection
|
347
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
348
|
+
Familia.config.encryption_keys = test_keys
|
349
|
+
Familia.config.current_key_version = :v1
|
350
|
+
|
351
|
+
user = JsonTamperTestModel.new(userid: 'user1')
|
352
|
+
user.secret_data = 'json-structure-test'
|
353
|
+
|
354
|
+
# Test invalid JSON structure
|
355
|
+
user.instance_variable_set(:@secret_data, '{"invalid": "json"')
|
356
|
+
user.secret_data
|
357
|
+
#=!> Familia::EncryptionError
|
358
|
+
#==> error.message.include?("Invalid JSON structure")
|
359
|
+
|
360
|
+
## Algorithm field tampering detection
|
361
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
362
|
+
Familia.config.encryption_keys = test_keys
|
363
|
+
Familia.config.current_key_version = :v1
|
364
|
+
|
365
|
+
user = SecurityTestModel10.new(user_id: 'user1')
|
366
|
+
user.password = 'algorithm-test'
|
367
|
+
encrypted = user.instance_variable_get(:@password)
|
368
|
+
|
369
|
+
# Tamper with algorithm field
|
370
|
+
parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
|
371
|
+
parsed[:algorithm] = 'unsupported_algorithm'
|
372
|
+
tampered_json = parsed.to_json
|
373
|
+
|
374
|
+
user.instance_variable_set(:@password, tampered_json)
|
375
|
+
user.password
|
376
|
+
#=!> Familia::EncryptionError
|
377
|
+
#==> error.message.include?("Unsupported algorithm")
|