familia 2.0.0.pre5 → 2.0.0.pre6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +8 -5
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +4 -3
  5. data/docs/wiki/API-Reference.md +95 -18
  6. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  7. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  8. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  9. data/docs/wiki/Feature-System-Guide.md +600 -0
  10. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  11. data/docs/wiki/Field-System-Guide.md +784 -0
  12. data/docs/wiki/Home.md +72 -15
  13. data/docs/wiki/Implementation-Guide.md +126 -33
  14. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  15. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  16. data/docs/wiki/Security-Model.md +65 -25
  17. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  18. data/lib/familia/base.rb +1 -1
  19. data/lib/familia/data_type/types/counter.rb +38 -0
  20. data/lib/familia/data_type/types/hashkey.rb +18 -0
  21. data/lib/familia/data_type/types/lock.rb +43 -0
  22. data/lib/familia/data_type/types/string.rb +9 -2
  23. data/lib/familia/data_type.rb +2 -2
  24. data/lib/familia/encryption/encrypted_data.rb +137 -0
  25. data/lib/familia/encryption/manager.rb +21 -4
  26. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  27. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  28. data/lib/familia/encryption.rb +1 -1
  29. data/lib/familia/errors.rb +17 -3
  30. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  31. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  32. data/lib/familia/features/expiration.rb +1 -1
  33. data/lib/familia/features/quantization.rb +1 -1
  34. data/lib/familia/features/safe_dump.rb +1 -1
  35. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  36. data/lib/familia/features/transient_fields.rb +1 -1
  37. data/lib/familia/field_type.rb +5 -2
  38. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  39. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  40. data/lib/familia/horreum/core/serialization.rb +535 -0
  41. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  42. data/lib/familia/horreum/core.rb +21 -0
  43. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  44. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +44 -28
  45. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  46. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  47. data/lib/familia/horreum.rb +17 -17
  48. data/lib/familia/version.rb +1 -1
  49. data/lib/familia.rb +1 -1
  50. data/try/core/create_method_try.rb +240 -0
  51. data/try/core/database_consistency_try.rb +299 -0
  52. data/try/core/errors_try.rb +25 -4
  53. data/try/core/familia_try.rb +1 -1
  54. data/try/core/persistence_operations_try.rb +297 -0
  55. data/try/data_types/counter_try.rb +93 -0
  56. data/try/data_types/lock_try.rb +133 -0
  57. data/try/debugging/debug_aad_process.rb +82 -0
  58. data/try/debugging/debug_concealed_internal.rb +59 -0
  59. data/try/debugging/debug_concealed_reveal.rb +61 -0
  60. data/try/debugging/debug_context_aad.rb +68 -0
  61. data/try/debugging/debug_context_simple.rb +80 -0
  62. data/try/debugging/debug_cross_context.rb +62 -0
  63. data/try/debugging/debug_database_load.rb +64 -0
  64. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  65. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  66. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  67. data/try/debugging/debug_field_decrypt.rb +74 -0
  68. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  69. data/try/debugging/debug_load_path.rb +66 -0
  70. data/try/debugging/debug_method_definition.rb +46 -0
  71. data/try/debugging/debug_method_resolution.rb +41 -0
  72. data/try/debugging/debug_minimal.rb +24 -0
  73. data/try/debugging/debug_provider.rb +68 -0
  74. data/try/debugging/debug_secure_behavior.rb +73 -0
  75. data/try/debugging/debug_string_class.rb +46 -0
  76. data/try/debugging/debug_test.rb +46 -0
  77. data/try/debugging/debug_test_design.rb +80 -0
  78. data/try/encryption/encryption_core_try.rb +3 -3
  79. data/try/features/encrypted_fields_core_try.rb +19 -11
  80. data/try/features/encrypted_fields_integration_try.rb +66 -70
  81. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  82. data/try/features/encrypted_fields_security_try.rb +151 -144
  83. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  84. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  85. data/try/features/encryption_fields/context_isolation_try.rb +29 -8
  86. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  87. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  88. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  89. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  90. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  91. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  92. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  93. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  94. data/try/features/feature_dependencies_try.rb +3 -3
  95. data/try/features/transient_fields_core_try.rb +1 -1
  96. data/try/features/transient_fields_integration_try.rb +1 -1
  97. data/try/helpers/test_helpers.rb +25 -0
  98. data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
  99. data/try/horreum/initialization_try.rb +1 -1
  100. data/try/horreum/relations_try.rb +1 -1
  101. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  102. data/try/horreum/serialization_try.rb +39 -4
  103. data/try/models/customer_safe_dump_try.rb +1 -1
  104. data/try/models/customer_try.rb +1 -1
  105. metadata +51 -10
  106. data/TEST_COVERAGE.md +0 -40
  107. data/lib/familia/horreum/serialization.rb +0 -473
@@ -3,22 +3,100 @@
3
3
  require_relative '../helpers/test_helpers'
4
4
  require 'base64'
5
5
 
6
-
7
- ## Context isolation: Different field contexts use different encryption
8
- test_keys = { v1: Base64.strict_encode64('a' * 32) }
9
- Familia.config.encryption_keys = test_keys
10
- Familia.config.current_key_version = :v1
6
+ # Define all test classes up front to avoid tryouts retry conflicts
11
7
 
12
8
  class SecurityTestModel < Familia::Horreum
13
9
  feature :encrypted_fields
14
10
  identifier_field :user_id
15
-
16
11
  field :user_id
17
12
  encrypted_field :password # No AAD
18
13
  encrypted_field :api_key # No AAD
19
14
  encrypted_field :secret_data # No AAD
20
15
  end
21
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
+
22
100
  user = SecurityTestModel.new(user_id: 'user1')
23
101
 
24
102
  user.password = 'same-value'
@@ -29,7 +107,6 @@ password_encrypted = user.instance_variable_get(:@password)
29
107
  api_key_encrypted = user.instance_variable_get(:@api_key)
30
108
  secret_data_encrypted = user.instance_variable_get(:@secret_data)
31
109
 
32
-
33
110
  [password_encrypted != api_key_encrypted,
34
111
  password_encrypted != secret_data_encrypted,
35
112
  api_key_encrypted != secret_data_encrypted]
@@ -40,21 +117,12 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
40
117
  Familia.config.encryption_keys = test_keys
41
118
  Familia.config.current_key_version = :v1
42
119
 
43
- class SecurityTestModel2 < Familia::Horreum
44
- feature :encrypted_fields
45
- identifier_field :user_id
46
-
47
- field :user_id
48
- field :email
49
- encrypted_field :api_key, aad_fields: [:email]
50
- end
51
-
52
120
  user1 = SecurityTestModel2.new(user_id: 'user1', email: 'user1@example.com')
53
121
  user2 = SecurityTestModel2.new(user_id: 'user2', email: 'user2@example.com')
54
122
 
55
123
  # Same value with different AAD should encrypt differently
56
- user1.api_key = 'same-api-key'
57
- user2.api_key = 'same-api-key'
124
+ user1.api_key = 'same-api-key-value'
125
+ user2.api_key = 'same-api-key-value'
58
126
 
59
127
  user1_encrypted = user1.instance_variable_get(:@api_key)
60
128
  user2_encrypted = user2.instance_variable_get(:@api_key)
@@ -67,19 +135,12 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
67
135
  Familia.config.encryption_keys = test_keys
68
136
  Familia.config.current_key_version = :v1
69
137
 
70
- class SecurityTestModel3 < Familia::Horreum
71
- feature :encrypted_fields
72
- identifier_field :user_id
73
- field :user_id
74
- encrypted_field :password
75
- end
76
-
77
138
  user = SecurityTestModel3.new(user_id: 'user1')
78
139
  user.password = 'test-password'
79
140
  encrypted = user.instance_variable_get(:@password)
80
141
 
81
142
  # Tamper with auth tag
82
- parsed = JSON.parse(encrypted, symbolize_names: true)
143
+ parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
83
144
  original_auth_tag = parsed[:auth_tag]
84
145
  tampered_auth_tag = original_auth_tag.dup
85
146
  tampered_auth_tag[0] = tampered_auth_tag[0] == 'A' ? 'B' : 'A'
@@ -88,7 +149,7 @@ tampered_json = parsed.to_json
88
149
 
89
150
  user.instance_variable_set(:@password, tampered_json)
90
151
  begin
91
- user.password
152
+ user.password.reveal { |plain| plain }
92
153
  "should_not_reach_here"
93
154
  rescue Familia::EncryptionError => e
94
155
  e.message.include?("Decryption failed")
@@ -100,19 +161,12 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
100
161
  Familia.config.encryption_keys = test_keys
101
162
  Familia.config.current_key_version = :v1
102
163
 
103
- class SecurityTestModel4 < Familia::Horreum
104
- feature :encrypted_fields
105
- identifier_field :user_id
106
- field :user_id
107
- encrypted_field :password
108
- end
109
-
110
164
  user = SecurityTestModel4.new(user_id: 'user1')
111
165
  user.password = 'test-password'
112
166
  encrypted = user.instance_variable_get(:@password)
113
167
 
114
168
  # Tamper with ciphertext
115
- parsed = JSON.parse(encrypted, symbolize_names: true)
169
+ parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
116
170
  original_ciphertext = parsed[:ciphertext]
117
171
  tampered_ciphertext = original_ciphertext.dup
118
172
  tampered_ciphertext[0] = tampered_ciphertext[0] == 'A' ? 'B' : 'A'
@@ -121,7 +175,7 @@ tampered_json = parsed.to_json
121
175
 
122
176
  user.instance_variable_set(:@password, tampered_json)
123
177
  begin
124
- user.password
178
+ user.password.reveal { |plain| plain }
125
179
  "should_not_reach_here"
126
180
  rescue Familia::EncryptionError => e
127
181
  e.message.include?("Decryption failed")
@@ -133,19 +187,12 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
133
187
  Familia.config.encryption_keys = test_keys
134
188
  Familia.config.current_key_version = :v1
135
189
 
136
- class SecurityTestModel5 < Familia::Horreum
137
- feature :encrypted_fields
138
- identifier_field :user_id
139
- field :user_id
140
- encrypted_field :password
141
- end
142
-
143
190
  user = SecurityTestModel5.new(user_id: 'user1')
144
191
  user.password = 'test-password'
145
192
  encrypted = user.instance_variable_get(:@password)
146
193
 
147
194
  # Tamper with nonce
148
- parsed = JSON.parse(encrypted, symbolize_names: true)
195
+ parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
149
196
  original_nonce = parsed[:nonce]
150
197
  tampered_nonce = original_nonce.dup
151
198
  tampered_nonce[0] = tampered_nonce[0] == 'A' ? 'B' : 'A'
@@ -154,7 +201,7 @@ tampered_json = parsed.to_json
154
201
 
155
202
  user.instance_variable_set(:@password, tampered_json)
156
203
  begin
157
- user.password
204
+ user.password.reveal { |plain| plain }
158
205
  "should_not_reach_here"
159
206
  rescue Familia::EncryptionError => e
160
207
  e.message.include?("Decryption failed")
@@ -166,26 +213,19 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
166
213
  Familia.config.encryption_keys = test_keys
167
214
  Familia.config.current_key_version = :v1
168
215
 
169
- class SecurityTestModel6 < Familia::Horreum
170
- feature :encrypted_fields
171
- identifier_field :user_id
172
- field :user_id
173
- encrypted_field :password
174
- end
175
-
176
216
  user = SecurityTestModel6.new(user_id: 'user1')
177
217
  user.password = 'key-isolation-test'
178
218
  encrypted_with_v1 = user.instance_variable_get(:@password)
179
219
 
180
220
 
181
221
  # Parse and change key version to non-existent version
182
- parsed = JSON.parse(encrypted_with_v1, symbolize_names: true)
222
+ parsed = JSON.parse(encrypted_with_v1.encrypted_value, symbolize_names: true)
183
223
  parsed[:key_version] = 'v999'
184
224
  modified_json = parsed.to_json
185
225
 
186
226
  user.instance_variable_set(:@password, modified_json)
187
227
  begin
188
- user.password
228
+ user.password.reveal { |plain| plain }
189
229
  "should_not_reach_here"
190
230
  rescue Familia::EncryptionError => e
191
231
  e.message.include?("No key for version")
@@ -193,19 +233,13 @@ end
193
233
  #=> true
194
234
 
195
235
  ## Nonce manipulation fails authentication - XChaCha20Poly1305 (24-byte nonces)
196
- class SecurityTestModelNonceXChaCha < Familia::Horreum
197
- feature :encrypted_fields
198
- identifier_field :user_id
199
- field :user_id
200
- encrypted_field :password
201
- end
202
236
 
203
237
  user = SecurityTestModelNonceXChaCha.new(user_id: 'user1')
204
238
  user.password = 'nonce-test-xchacha'
205
239
  encrypted_with_nonce = user.instance_variable_get(:@password)
206
240
 
207
241
  # Parse and modify nonce (XChaCha20Poly1305 uses 24-byte nonces)
208
- parsed = JSON.parse(encrypted_with_nonce, symbolize_names: true)
242
+ parsed = JSON.parse(encrypted_with_nonce.encrypted_value, symbolize_names: true)
209
243
  original_nonce = parsed[:nonce]
210
244
  # Create a different valid base64 nonce for XChaCha20Poly1305 (24 bytes)
211
245
  different_nonce = Base64.strict_encode64('x' * 24)
@@ -214,7 +248,7 @@ modified_json = parsed.to_json
214
248
 
215
249
  user.instance_variable_set(:@password, modified_json)
216
250
  begin
217
- user.password
251
+ user.password.reveal { |plain| plain }
218
252
  "should_not_reach_here"
219
253
  rescue Familia::EncryptionError => e
220
254
  e.message.include?("Decryption failed")
@@ -222,12 +256,6 @@ end
222
256
  #=> true
223
257
 
224
258
  ## Nonce manipulation fails authentication - AES-GCM (12-byte nonces)
225
- class SecurityTestModelNonceAES < Familia::Horreum
226
- feature :encrypted_fields
227
- identifier_field :user_id
228
- field :user_id
229
- encrypted_field :password
230
- end
231
259
 
232
260
  user_aes = SecurityTestModelNonceAES.new(user_id: 'user2')
233
261
  # Force AES-GCM encryption for this test
@@ -245,7 +273,7 @@ modified_json_aes = parsed_aes.to_json
245
273
 
246
274
  user_aes.instance_variable_set(:@password, modified_json_aes)
247
275
  begin
248
- user_aes.password
276
+ user_aes.password.reveal { |plain| plain }
249
277
  "should_not_reach_here"
250
278
  rescue Familia::EncryptionError => e
251
279
  e.message.include?("Decryption failed")
@@ -257,62 +285,62 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
257
285
  Familia.config.encryption_keys = test_keys
258
286
  Familia.config.current_key_version = :v1
259
287
 
260
- class SecurityTestModel7 < Familia::Horreum
261
- feature :encrypted_fields
262
- identifier_field :user_id
263
- field :user_id
264
- encrypted_field :field_a
265
- encrypted_field :field_b
266
- end
267
-
268
- user = SecurityTestModel7.new(user_id: 'user1')
269
- user.field_a = 'test-value-a'
270
- user.field_b = 'test-value-b'
271
- #=> 'test-value-b'
288
+ user = SecurityTestModel7.new(user_id: 'cache-user')
289
+ user.password = 'cache-test'
272
290
 
273
- # Different contexts should cache different keys
274
- Thread.current[:familia_key_cache].nil?
275
- #=> true
291
+ # Use different encryption context
292
+ other_user = SecurityTestModel8.new(user_id: 'cache-user')
293
+ other_user.password = 'cache-test'
276
294
 
277
- # Different contexts should cache different keys
278
- cache = Thread.current[:familia_key_cache]
279
- cache&.keys
280
- #=> nil
295
+ user_encrypted = user.instance_variable_get(:@password)
296
+ other_encrypted = other_user.instance_variable_get(:@password)
281
297
 
282
- # Should have different cache entries for different field contexts
283
- cache = Thread.current[:familia_key_cache]
284
- cache&.keys&.length >= 2
285
- ##=> true
298
+ # Different classes should have different key caches
299
+ user_encrypted != other_encrypted
300
+ #=> true
286
301
 
287
- ## Thread-local key cache independence
302
+ ## Cross-user encrypted data should not decrypt
288
303
  test_keys = { v1: Base64.strict_encode64('a' * 32) }
289
304
  Familia.config.encryption_keys = test_keys
290
305
  Familia.config.current_key_version = :v1
291
306
 
292
- class SecurityTestModel8 < Familia::Horreum
293
- feature :encrypted_fields
294
- identifier_field :user_id
295
- field :user_id
296
- encrypted_field :password
297
- end
307
+ user1 = SecurityTestModel7.new(user_id: 'user1')
308
+ user2 = SecurityTestModel7.new(user_id: 'user2')
298
309
 
299
- # Clear any existing cache
300
- Thread.current[:familia_key_cache] = nil
310
+ user1.password = 'user1-password'
311
+ user1_encrypted = user1.instance_variable_get(:@password)
301
312
 
302
- user = SecurityTestModel8.new(user_id: 'user1')
303
- user.password = 'thread-cache-test'
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
304
329
 
305
- # Cache should be created for this thread
306
- cache_before = Thread.current[:familia_key_cache]
307
- cache_before.is_a?(Hash) && !cache_before.empty?
308
- #=> false
330
+ user = SecurityTestModel8.new(user_id: 'thread-user')
331
+ user.password = 'thread-test'
332
+ main_encrypted = user.instance_variable_get(:@password)
309
333
 
310
- # Clear cache manually
311
- Thread.current[:familia_key_cache] = {}
312
- cache_after = Thread.current[:familia_key_cache]
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
313
340
 
314
- # Cache should be empty after clearing
315
- cache_after.empty?
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
316
344
  #=> true
317
345
 
318
346
  ## JSON structure tampering detection
@@ -320,51 +348,30 @@ test_keys = { v1: Base64.strict_encode64('a' * 32) }
320
348
  Familia.config.encryption_keys = test_keys
321
349
  Familia.config.current_key_version = :v1
322
350
 
323
- class SecurityTestModel9 < Familia::Horreum
324
- feature :encrypted_fields
325
- identifier_field :user_id
326
- field :user_id
327
- encrypted_field :password
328
- end
329
-
330
- user = SecurityTestModel9.new(user_id: 'user1')
331
- user.password = 'json-structure-test'
351
+ user = JsonTamperTestModel.new(userid: 'user1')
352
+ user.secret_data = 'json-structure-test'
332
353
 
333
354
  # Test invalid JSON structure
334
- user.instance_variable_set(:@password, '{"invalid": "json"')
335
- user.password
355
+ user.instance_variable_set(:@secret_data, '{"invalid": "json"')
356
+ user.secret_data
336
357
  #=!> Familia::EncryptionError
337
- #==> error.message.include?("Decryption failed")
358
+ #==> error.message.include?("Invalid JSON structure")
338
359
 
339
360
  ## Algorithm field tampering detection
340
361
  test_keys = { v1: Base64.strict_encode64('a' * 32) }
341
362
  Familia.config.encryption_keys = test_keys
342
363
  Familia.config.current_key_version = :v1
343
364
 
344
- class SecurityTestModel10 < Familia::Horreum
345
- feature :encrypted_fields
346
- identifier_field :user_id
347
- field :user_id
348
- encrypted_field :password
349
- end
350
-
351
365
  user = SecurityTestModel10.new(user_id: 'user1')
352
- user.password = 'algorithm-tamper-test'
366
+ user.password = 'algorithm-test'
353
367
  encrypted = user.instance_variable_get(:@password)
354
368
 
355
369
  # Tamper with algorithm field
356
- parsed = JSON.parse(encrypted, symbolize_names: true)
357
- parsed[:algorithm] = 'tampered-algorithm'
370
+ parsed = JSON.parse(encrypted.encrypted_value, symbolize_names: true)
371
+ parsed[:algorithm] = 'unsupported_algorithm'
358
372
  tampered_json = parsed.to_json
359
373
 
360
374
  user.instance_variable_set(:@password, tampered_json)
361
- begin
362
- user.password
363
- "should_not_reach_here"
364
- rescue Familia::EncryptionError => e
365
- e.message.include?("Unsupported algorithm")
366
- end
367
- #=> true
368
-
369
- # TEARDOWN
370
- Thread.current[:familia_key_cache]&.clear if Thread.current[:familia_key_cache]
375
+ user.password
376
+ #=!> Familia::EncryptionError
377
+ #==> error.message.include?("Unsupported algorithm")
@@ -19,35 +19,120 @@ class AADProtectedModel < Familia::Horreum
19
19
  encrypted_field :api_key, aad_fields: [:email]
20
20
  end
21
21
 
22
- ## AAD prevents field substitution attacks when record exists
23
- model = AADProtectedModel.new(id: 'aad-1', email: 'user@example.com')
24
- model.save # Need to save first for AAD to be active
25
- model.api_key = 'secret-key'
26
- model.save # Save the encrypted value
27
- ciphertext = model.instance_variable_get(:@api_key)
28
-
29
- # Change AAD field after encryption
30
- model.email = 'attacker@evil.com'
31
- model.instance_variable_set(:@api_key, ciphertext)
32
-
33
- begin
34
- model.api_key
35
- false
36
- rescue Familia::EncryptionError
37
- true
22
+ # Clean test environment
23
+ Familia.dbclient.flushdb
24
+
25
+ ## AAD prevents field substitution attacks - proper cross-record test
26
+ @victim = AADProtectedModel.new(id: 'victim-1', email: 'victim@example.com')
27
+ @victim.save # Need to save first for AAD to be active
28
+ @victim.api_key = 'victim-secret-key'
29
+ @victim.save # Save the encrypted value
30
+
31
+ # Extract the raw encrypted JSON data (not the ConcealedString object)
32
+ @victim_encrypted_data = @victim.api_key.encrypted_value
33
+
34
+ # Create an attacker record with different AAD context (different email)
35
+ @attacker = AADProtectedModel.new(id: 'attacker-1', email: 'attacker@evil.com')
36
+ @attacker.save # Need to save for AAD to be active
37
+
38
+ # Simulate database tampering: set attacker's field to victim's encrypted data
39
+ # This simulates what an attacker with database access might try to do
40
+ @attacker.instance_variable_set(:@api_key,
41
+ ConcealedString.new(@victim_encrypted_data, @attacker, @attacker.class.field_types[:api_key]))
42
+
43
+ # Attempt to decrypt should fail due to AAD mismatch
44
+ @result1 = begin
45
+ decrypted_value = nil
46
+ @attacker.api_key.reveal { |plaintext| decrypted_value = plaintext }
47
+ "UNEXPECTED SUCCESS: #{decrypted_value}"
48
+ rescue Familia::EncryptionError => error
49
+ error.class.name
38
50
  end
51
+ @result1
52
+ #=> "Familia::EncryptionError"
53
+
54
+ ## Verify error message indicates decryption failure
55
+ @result2 = begin
56
+ @attacker.api_key.reveal { |plaintext| plaintext }
57
+ "No error occurred"
58
+ rescue Familia::EncryptionError => error
59
+ error.message.include?('Decryption failed')
60
+ end
61
+ @result2
39
62
  #=> true
40
63
 
41
- ## Without saving, AAD is not enforced (returns nil)
42
- unsaved_model = AADProtectedModel.new(id: 'aad-2', email: 'test@example.com')
64
+ ## Cross-record attack with same email (should still fail due to different identifiers)
65
+ victim2 = AADProtectedModel.new(id: 'victim-2', email: 'shared@example.com')
66
+ victim2.save
67
+ victim2.api_key = 'victim2-secret'
68
+ victim2.save
69
+
70
+ attacker2 = AADProtectedModel.new(id: 'attacker-2', email: 'shared@example.com') # Same email!
71
+ attacker2.save
72
+
73
+ # Extract victim's encrypted data and try to decrypt with attacker's context
74
+ victim2_encrypted_data = victim2.api_key.encrypted_value
75
+ attacker2.instance_variable_set(:@api_key,
76
+ ConcealedString.new(victim2_encrypted_data, attacker2, attacker2.class.field_types[:api_key]))
77
+
78
+ # Should fail because identifier is part of AAD even when aad_fields match
79
+ @result3 = begin
80
+ attacker2.api_key.reveal { |plaintext| plaintext }
81
+ "UNEXPECTED SUCCESS"
82
+ rescue Familia::EncryptionError => error
83
+ error.class.name
84
+ end
85
+ @result3
86
+ #=> "Familia::EncryptionError"
87
+
88
+ ## Without saving, AAD is not enforced (no database context)
89
+ unsaved_model = AADProtectedModel.new(id: 'unsaved-1', email: 'test@example.com')
43
90
  unsaved_model.api_key = 'test-key'
44
- stored = unsaved_model.instance_variable_get(:@api_key)
45
- # Change email and it still decrypts (no AAD protection without save)
91
+
92
+ # Change email after encryption but before save - should still work
46
93
  unsaved_model.email = 'changed@example.com'
47
- unsaved_model.instance_variable_set(:@api_key, stored)
48
- unsaved_model.api_key
49
- #=> 'test-key'
94
+ decrypted = nil
95
+ unsaved_model.api_key.reveal { |plaintext| decrypted = plaintext }
96
+ decrypted
97
+ #=> "test-key"
98
+
99
+ ## Cross-model attack with raw encrypted JSON
100
+ # Demonstrate that raw encrypted data can't be moved between models
101
+ @json_victim = AADProtectedModel.new(id: 'json-victim-1', email: 'jsonvictim@example.com')
102
+ @json_victim.save
103
+ @json_victim.api_key = 'json-victim-secret'
104
+
105
+ # Get the raw encrypted JSON and create a new ConcealedString for different record
106
+ @raw_encrypted_json = @json_victim.api_key.encrypted_value
107
+ @json_attacker = AADProtectedModel.new(id: 'json-attacker-1', email: 'jsonattacker@evil.com')
108
+ @json_attacker.save
109
+
110
+ # Create ConcealedString with stolen encrypted JSON for the attacker
111
+ @fake_concealed = ConcealedString.new(@raw_encrypted_json, @json_attacker, @json_attacker.class.field_types[:api_key])
112
+
113
+ # Attempt decryption should fail
114
+ @result4 = begin
115
+ @fake_concealed.reveal { |plaintext| plaintext }
116
+ "UNEXPECTED SUCCESS"
117
+ rescue Familia::EncryptionError => error
118
+ error.class.name
119
+ end
120
+ @result4
121
+ #=> "Familia::EncryptionError"
122
+
123
+ ## Successful decryption with correct context (control test)
124
+ legitimate_user = AADProtectedModel.new(id: 'legitimate-1', email: 'legit@example.com')
125
+ legitimate_user.save
126
+ legitimate_user.api_key = 'legitimate-secret'
127
+ legitimate_user.save
128
+
129
+ # Normal decryption should work
130
+ decrypted_legit = nil
131
+ legitimate_user.api_key.reveal { |plaintext| decrypted_legit = plaintext }
132
+ decrypted_legit
133
+ #=> "legitimate-secret"
50
134
 
51
135
  # Cleanup
136
+ Familia.dbclient.flushdb
52
137
  Familia.config.encryption_keys = nil
53
138
  Familia.config.current_key_version = nil