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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.rubocop_todo.yml +17 -17
- data/CLAUDE.md +3 -3
- data/Gemfile +5 -1
- data/Gemfile.lock +18 -3
- data/README.md +36 -157
- data/TEST_COVERAGE.md +40 -0
- data/docs/overview.md +359 -0
- data/docs/wiki/API-Reference.md +270 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
- data/docs/wiki/Home.md +49 -0
- data/docs/wiki/Implementation-Guide.md +183 -0
- data/docs/wiki/Security-Model.md +143 -0
- data/lib/familia/base.rb +18 -27
- data/lib/familia/connection.rb +6 -5
- data/lib/familia/{datatype → data_type}/commands.rb +2 -5
- data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
- data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
- data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
- data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
- data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
- data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
- data/lib/familia/{datatype.rb → data_type.rb} +10 -12
- data/lib/familia/encryption/manager.rb +102 -0
- data/lib/familia/encryption/provider.rb +49 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
- data/lib/familia/encryption/registry.rb +50 -0
- data/lib/familia/encryption.rb +178 -0
- data/lib/familia/encryption_request_cache.rb +68 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
- data/lib/familia/features/encrypted_fields.rb +28 -0
- data/lib/familia/features/expiration.rb +107 -77
- data/lib/familia/features/quantization.rb +5 -9
- data/lib/familia/features/relatable_objects.rb +2 -4
- data/lib/familia/features/safe_dump.rb +14 -17
- data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
- data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
- data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
- data/lib/familia/features/transient_fields.rb +47 -0
- data/lib/familia/features.rb +40 -24
- data/lib/familia/field_type.rb +270 -0
- data/lib/familia/horreum/connection.rb +8 -11
- data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
- data/lib/familia/horreum/definition_methods.rb +453 -0
- data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -229
- data/lib/familia/horreum/serialization.rb +46 -18
- data/lib/familia/horreum/settings.rb +10 -2
- data/lib/familia/horreum/utils.rb +9 -10
- data/lib/familia/horreum.rb +18 -10
- data/lib/familia/logging.rb +14 -14
- data/lib/familia/settings.rb +39 -3
- data/lib/familia/utils.rb +45 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +2 -1
- data/try/core/base_enhancements_try.rb +115 -0
- data/try/core/connection_try.rb +0 -1
- data/try/core/errors_try.rb +0 -1
- data/try/core/familia_extended_try.rb +3 -4
- data/try/core/familia_try.rb +0 -1
- data/try/core/pools_try.rb +2 -2
- data/try/core/secure_identifier_try.rb +0 -1
- data/try/core/settings_try.rb +0 -1
- data/try/core/utils_try.rb +0 -1
- data/try/{datatypes → data_types}/boolean_try.rb +1 -2
- data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
- data/try/{datatypes → data_types}/hash_try.rb +1 -2
- data/try/{datatypes → data_types}/list_try.rb +1 -2
- data/try/{datatypes → data_types}/set_try.rb +1 -2
- data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
- data/try/{datatypes → data_types}/string_try.rb +1 -2
- data/try/debugging/README.md +32 -0
- data/try/debugging/cache_behavior_tracer.rb +91 -0
- data/try/debugging/encryption_method_tracer.rb +138 -0
- data/try/debugging/provider_diagnostics.rb +110 -0
- data/try/edge_cases/hash_symbolization_try.rb +0 -1
- data/try/edge_cases/json_serialization_try.rb +0 -1
- data/try/edge_cases/reserved_keywords_try.rb +42 -11
- data/try/encryption/config_persistence_try.rb +192 -0
- data/try/encryption/encryption_core_try.rb +328 -0
- data/try/encryption/instance_variable_scope_try.rb +31 -0
- data/try/encryption/module_loading_try.rb +28 -0
- data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
- data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
- data/try/encryption/roundtrip_validation_try.rb +28 -0
- data/try/encryption/secure_memory_handling_try.rb +125 -0
- data/try/features/encrypted_fields_core_try.rb +117 -0
- data/try/features/encrypted_fields_integration_try.rb +220 -0
- data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
- data/try/features/encrypted_fields_security_try.rb +370 -0
- data/try/features/encryption_fields/aad_protection_try.rb +53 -0
- data/try/features/encryption_fields/context_isolation_try.rb +120 -0
- data/try/features/encryption_fields/error_conditions_try.rb +116 -0
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
- data/try/features/encryption_fields/fresh_key_try.rb +163 -0
- data/try/features/encryption_fields/key_rotation_try.rb +117 -0
- data/try/features/encryption_fields/memory_security_try.rb +37 -0
- data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
- data/try/features/encryption_fields/thread_safety_try.rb +199 -0
- data/try/features/expiration_try.rb +0 -1
- data/try/features/feature_dependencies_try.rb +159 -0
- data/try/features/quantization_try.rb +0 -1
- data/try/features/real_feature_integration_try.rb +148 -0
- data/try/features/relatable_objects_try.rb +0 -1
- data/try/features/safe_dump_advanced_try.rb +0 -1
- data/try/features/safe_dump_try.rb +0 -1
- data/try/features/transient_fields/redacted_string_try.rb +248 -0
- data/try/features/transient_fields/refresh_reset_try.rb +164 -0
- data/try/features/transient_fields/simple_refresh_test.rb +50 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
- data/try/features/transient_fields_core_try.rb +181 -0
- data/try/features/transient_fields_integration_try.rb +260 -0
- data/try/helpers/test_helpers.rb +42 -0
- data/try/horreum/base_try.rb +157 -3
- data/try/horreum/class_methods_try.rb +27 -36
- data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
- data/try/horreum/field_categories_try.rb +118 -0
- data/try/horreum/field_definition_try.rb +96 -0
- data/try/horreum/initialization_try.rb +0 -1
- data/try/horreum/relations_try.rb +0 -1
- data/try/horreum/serialization_persistent_fields_try.rb +165 -0
- data/try/horreum/serialization_try.rb +2 -3
- data/try/memory/memory_basic_test.rb +73 -0
- data/try/memory/memory_detailed_test.rb +121 -0
- data/try/memory/memory_docker_ruby_dump.sh +80 -0
- data/try/memory/memory_search_for_string.rb +83 -0
- data/try/memory/test_actual_redactedstring_protection.rb +38 -0
- data/try/models/customer_safe_dump_try.rb +0 -1
- data/try/models/customer_try.rb +0 -1
- data/try/models/datatype_base_try.rb +1 -2
- data/try/models/familia_object_try.rb +0 -1
- 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
|