familia 2.0.0.pre4 → 2.0.0.pre6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +11 -8
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +19 -3
  7. data/README.md +36 -157
  8. data/docs/overview.md +359 -0
  9. data/docs/wiki/API-Reference.md +347 -0
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +600 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +106 -0
  17. data/docs/wiki/Implementation-Guide.md +276 -0
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  20. data/docs/wiki/Security-Model.md +183 -0
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/lib/familia/base.rb +18 -27
  23. data/lib/familia/connection.rb +6 -5
  24. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  25. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  26. data/lib/familia/data_type/types/counter.rb +38 -0
  27. data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
  28. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  31. data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
  32. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  33. data/lib/familia/{datatype.rb → data_type.rb} +12 -14
  34. data/lib/familia/encryption/encrypted_data.rb +137 -0
  35. data/lib/familia/encryption/manager.rb +119 -0
  36. data/lib/familia/encryption/provider.rb +49 -0
  37. data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
  38. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  39. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -0
  40. data/lib/familia/encryption/registry.rb +50 -0
  41. data/lib/familia/encryption.rb +178 -0
  42. data/lib/familia/encryption_request_cache.rb +68 -0
  43. data/lib/familia/errors.rb +17 -3
  44. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  45. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -0
  46. data/lib/familia/features/encrypted_fields.rb +28 -0
  47. data/lib/familia/features/expiration.rb +107 -77
  48. data/lib/familia/features/quantization.rb +5 -9
  49. data/lib/familia/features/relatable_objects.rb +2 -4
  50. data/lib/familia/features/safe_dump.rb +14 -17
  51. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  52. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  53. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  54. data/lib/familia/features/transient_fields.rb +47 -0
  55. data/lib/familia/features.rb +40 -24
  56. data/lib/familia/field_type.rb +273 -0
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
  58. data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
  63. data/lib/familia/horreum/subclass/definition.rb +469 -0
  64. data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +30 -22
  67. data/lib/familia/logging.rb +14 -14
  68. data/lib/familia/settings.rb +39 -3
  69. data/lib/familia/utils.rb +45 -0
  70. data/lib/familia/version.rb +1 -1
  71. data/lib/familia.rb +3 -2
  72. data/try/core/base_enhancements_try.rb +115 -0
  73. data/try/core/connection_try.rb +0 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -5
  77. data/try/core/familia_extended_try.rb +3 -4
  78. data/try/core/familia_try.rb +1 -2
  79. data/try/core/persistence_operations_try.rb +297 -0
  80. data/try/core/pools_try.rb +2 -2
  81. data/try/core/secure_identifier_try.rb +0 -1
  82. data/try/core/settings_try.rb +0 -1
  83. data/try/core/utils_try.rb +0 -1
  84. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  85. data/try/data_types/counter_try.rb +93 -0
  86. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  87. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  88. data/try/{datatypes → data_types}/list_try.rb +1 -2
  89. data/try/data_types/lock_try.rb +133 -0
  90. data/try/{datatypes → data_types}/set_try.rb +1 -2
  91. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  92. data/try/{datatypes → data_types}/string_try.rb +1 -2
  93. data/try/debugging/README.md +32 -0
  94. data/try/debugging/cache_behavior_tracer.rb +91 -0
  95. data/try/debugging/debug_aad_process.rb +82 -0
  96. data/try/debugging/debug_concealed_internal.rb +59 -0
  97. data/try/debugging/debug_concealed_reveal.rb +61 -0
  98. data/try/debugging/debug_context_aad.rb +68 -0
  99. data/try/debugging/debug_context_simple.rb +80 -0
  100. data/try/debugging/debug_cross_context.rb +62 -0
  101. data/try/debugging/debug_database_load.rb +64 -0
  102. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  103. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  104. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  105. data/try/debugging/debug_field_decrypt.rb +74 -0
  106. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  107. data/try/debugging/debug_load_path.rb +66 -0
  108. data/try/debugging/debug_method_definition.rb +46 -0
  109. data/try/debugging/debug_method_resolution.rb +41 -0
  110. data/try/debugging/debug_minimal.rb +24 -0
  111. data/try/debugging/debug_provider.rb +68 -0
  112. data/try/debugging/debug_secure_behavior.rb +73 -0
  113. data/try/debugging/debug_string_class.rb +46 -0
  114. data/try/debugging/debug_test.rb +46 -0
  115. data/try/debugging/debug_test_design.rb +80 -0
  116. data/try/debugging/encryption_method_tracer.rb +138 -0
  117. data/try/debugging/provider_diagnostics.rb +110 -0
  118. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  119. data/try/edge_cases/json_serialization_try.rb +0 -1
  120. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  121. data/try/encryption/config_persistence_try.rb +192 -0
  122. data/try/encryption/encryption_core_try.rb +328 -0
  123. data/try/encryption/instance_variable_scope_try.rb +31 -0
  124. data/try/encryption/module_loading_try.rb +28 -0
  125. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  126. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  127. data/try/encryption/roundtrip_validation_try.rb +28 -0
  128. data/try/encryption/secure_memory_handling_try.rb +125 -0
  129. data/try/features/encrypted_fields_core_try.rb +125 -0
  130. data/try/features/encrypted_fields_integration_try.rb +216 -0
  131. data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
  132. data/try/features/encrypted_fields_security_try.rb +377 -0
  133. data/try/features/encryption_fields/aad_protection_try.rb +138 -0
  134. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  135. data/try/features/encryption_fields/context_isolation_try.rb +141 -0
  136. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  137. data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
  138. data/try/features/encryption_fields/fresh_key_try.rb +168 -0
  139. data/try/features/encryption_fields/key_rotation_try.rb +123 -0
  140. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  141. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  142. data/try/features/encryption_fields/nonce_uniqueness_try.rb +56 -0
  143. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  144. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  145. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  146. data/try/features/expiration_try.rb +0 -1
  147. data/try/features/feature_dependencies_try.rb +159 -0
  148. data/try/features/quantization_try.rb +0 -1
  149. data/try/features/real_feature_integration_try.rb +148 -0
  150. data/try/features/relatable_objects_try.rb +0 -1
  151. data/try/features/safe_dump_advanced_try.rb +0 -1
  152. data/try/features/safe_dump_try.rb +0 -1
  153. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  154. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  155. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  156. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  157. data/try/features/transient_fields_core_try.rb +181 -0
  158. data/try/features/transient_fields_integration_try.rb +260 -0
  159. data/try/helpers/test_helpers.rb +67 -0
  160. data/try/horreum/base_try.rb +157 -3
  161. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  162. data/try/horreum/field_categories_try.rb +118 -0
  163. data/try/horreum/field_definition_try.rb +96 -0
  164. data/try/horreum/initialization_try.rb +1 -2
  165. data/try/horreum/relations_try.rb +1 -2
  166. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  167. data/try/horreum/serialization_try.rb +41 -7
  168. data/try/memory/memory_basic_test.rb +73 -0
  169. data/try/memory/memory_detailed_test.rb +121 -0
  170. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  171. data/try/memory/memory_search_for_string.rb +83 -0
  172. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  173. data/try/models/customer_safe_dump_try.rb +1 -2
  174. data/try/models/customer_try.rb +1 -2
  175. data/try/models/datatype_base_try.rb +1 -2
  176. data/try/models/familia_object_try.rb +0 -1
  177. metadata +131 -23
  178. data/lib/familia/horreum/serialization.rb +0 -445
@@ -0,0 +1,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")