familia 2.0.0.pre3 → 2.0.0.pre5

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