familia 2.0.0.pre5 → 2.0.0.pre7
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/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -10
- data/Gemfile +2 -2
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +631 -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 +82 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +9 -6
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +6 -4
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
- data/try/features/encryption_fields/context_isolation_try.rb +30 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +26 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +2 -2
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +81 -12
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/lib/familia/horreum/serialization.rb +0 -473
- data/try/features/relatable_objects_try.rb +0 -220
@@ -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
|
-
|
261
|
-
|
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
|
-
#
|
274
|
-
|
275
|
-
|
291
|
+
# Use different encryption context
|
292
|
+
other_user = SecurityTestModel8.new(user_id: 'cache-user')
|
293
|
+
other_user.password = 'cache-test'
|
276
294
|
|
277
|
-
|
278
|
-
|
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
|
-
#
|
283
|
-
|
284
|
-
|
285
|
-
##=> true
|
298
|
+
# Different classes should have different key caches
|
299
|
+
user_encrypted != other_encrypted
|
300
|
+
#=> true
|
286
301
|
|
287
|
-
##
|
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
|
-
|
293
|
-
|
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
|
-
|
300
|
-
|
310
|
+
user1.password = 'user1-password'
|
311
|
+
user1_encrypted = user1.instance_variable_get(:@password)
|
301
312
|
|
302
|
-
|
303
|
-
|
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
|
-
|
306
|
-
|
307
|
-
|
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
|
-
|
311
|
-
Thread.
|
312
|
-
|
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
|
-
#
|
315
|
-
|
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
|
-
|
324
|
-
|
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(:@
|
335
|
-
user.
|
355
|
+
user.instance_variable_set(:@secret_data, '{"invalid": "json"')
|
356
|
+
user.secret_data
|
336
357
|
#=!> Familia::EncryptionError
|
337
|
-
#==> error.message.include?("
|
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-
|
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] = '
|
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
|
-
|
362
|
-
|
363
|
-
|
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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
#
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
##
|
42
|
-
|
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
|
-
|
45
|
-
# Change email
|
91
|
+
|
92
|
+
# Change email after encryption but before save - should still work
|
46
93
|
unsaved_model.email = 'changed@example.com'
|
47
|
-
|
48
|
-
unsaved_model.api_key
|
49
|
-
|
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
|