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,120 @@
|
|
1
|
+
# try/features/encryption_fields/context_isolation_try.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
require_relative '../../helpers/test_helpers'
|
6
|
+
|
7
|
+
# Setup encryption keys for testing
|
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
|
+
class IsolationUser < Familia::Horreum
|
16
|
+
feature :encrypted_fields
|
17
|
+
identifier_field :user_id
|
18
|
+
field :user_id
|
19
|
+
encrypted_field :secret
|
20
|
+
end
|
21
|
+
|
22
|
+
# Different models with same field name have isolated contexts
|
23
|
+
class ModelA < Familia::Horreum
|
24
|
+
feature :encrypted_fields
|
25
|
+
identifier_field :id
|
26
|
+
field :id
|
27
|
+
encrypted_field :api_key
|
28
|
+
end
|
29
|
+
|
30
|
+
class ModelB < Familia::Horreum
|
31
|
+
feature :encrypted_fields
|
32
|
+
identifier_field :id
|
33
|
+
field :id
|
34
|
+
encrypted_field :api_key
|
35
|
+
end
|
36
|
+
|
37
|
+
## Different user IDs produce different ciphertexts for same plaintext
|
38
|
+
@user1 = IsolationUser.new(user_id: 'alice')
|
39
|
+
@user2 = IsolationUser.new(user_id: 'bob')
|
40
|
+
|
41
|
+
@user1.secret = 'shared-secret'
|
42
|
+
@user2.secret = 'shared-secret'
|
43
|
+
|
44
|
+
@cipher1 = @user1.instance_variable_get(:@secret)
|
45
|
+
@cipher2 = @user2.instance_variable_get(:@secret)
|
46
|
+
|
47
|
+
@cipher1 != @cipher2
|
48
|
+
#=> true
|
49
|
+
|
50
|
+
## Same plaintext decrypts correctly for both users
|
51
|
+
@user1.secret
|
52
|
+
#=> 'shared-secret'
|
53
|
+
|
54
|
+
## Same plaintext decrypts correctly for both users
|
55
|
+
@user2.secret
|
56
|
+
#=> 'shared-secret'
|
57
|
+
|
58
|
+
## Different model classes have isolated encryption contexts
|
59
|
+
@model_a = ModelA.new(id: 'same-id')
|
60
|
+
@model_b = ModelB.new(id: 'same-id')
|
61
|
+
|
62
|
+
@model_a.api_key = 'secret-key'
|
63
|
+
@model_b.api_key = 'secret-key'
|
64
|
+
|
65
|
+
@cipher_a = @model_a.instance_variable_get(:@api_key)
|
66
|
+
@cipher_b = @model_b.instance_variable_get(:@api_key)
|
67
|
+
|
68
|
+
@cipher_a != @cipher_b
|
69
|
+
#=> true
|
70
|
+
|
71
|
+
## Model A can decrypt its own data
|
72
|
+
@model_a.api_key
|
73
|
+
#=> 'secret-key'
|
74
|
+
|
75
|
+
## Model B can decrypt its own data
|
76
|
+
@model_b.api_key
|
77
|
+
#=> 'secret-key'
|
78
|
+
|
79
|
+
## Cross-model decryption fails due to context mismatch
|
80
|
+
@model_a.instance_variable_set(:@api_key, @cipher_b)
|
81
|
+
begin
|
82
|
+
@model_a.api_key
|
83
|
+
false
|
84
|
+
rescue Familia::EncryptionError
|
85
|
+
true
|
86
|
+
end
|
87
|
+
#=> true
|
88
|
+
|
89
|
+
## Different field names in same model create different contexts
|
90
|
+
class MultiFieldModel < Familia::Horreum
|
91
|
+
feature :encrypted_fields
|
92
|
+
identifier_field :id
|
93
|
+
field :id
|
94
|
+
encrypted_field :field_one
|
95
|
+
encrypted_field :field_two
|
96
|
+
end
|
97
|
+
|
98
|
+
@multi = MultiFieldModel.new(id: 'test')
|
99
|
+
@multi.field_one = 'same-value'
|
100
|
+
@multi.field_two = 'same-value'
|
101
|
+
|
102
|
+
@cipher_field1 = @multi.instance_variable_get(:@field_one)
|
103
|
+
@cipher_field2 = @multi.instance_variable_get(:@field_two)
|
104
|
+
|
105
|
+
@cipher_field1 != @cipher_field2
|
106
|
+
#=> true
|
107
|
+
|
108
|
+
## Cross-field decryption fails due to field context isolation
|
109
|
+
@multi.instance_variable_set(:@field_one, @cipher_field2)
|
110
|
+
begin
|
111
|
+
@multi.field_one
|
112
|
+
false
|
113
|
+
rescue Familia::EncryptionError
|
114
|
+
true
|
115
|
+
end
|
116
|
+
#=> true
|
117
|
+
|
118
|
+
# Cleanup
|
119
|
+
Familia.config.encryption_keys = nil
|
120
|
+
Familia.config.current_key_version = nil
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# try/features/encryption_fields/error_conditions_try.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
require_relative '../../helpers/test_helpers'
|
6
|
+
|
7
|
+
# Setup encryption keys for error testing
|
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
|
+
class ErrorTest < Familia::Horreum
|
16
|
+
feature :encrypted_fields
|
17
|
+
identifier_field :id
|
18
|
+
field :id
|
19
|
+
encrypted_field :secret
|
20
|
+
end
|
21
|
+
|
22
|
+
## Malformed JSON raises appropriate error
|
23
|
+
@model = ErrorTest.new(id: 'err-1')
|
24
|
+
@model.instance_variable_set(:@secret, 'not-json{]')
|
25
|
+
@model.secret
|
26
|
+
#=!> Familia::EncryptionError
|
27
|
+
#==> error.message.include?('Decryption failed')
|
28
|
+
|
29
|
+
## Tampered auth tag fails decryption
|
30
|
+
@model.secret = 'valid-secret'
|
31
|
+
@valid_cipher = @model.instance_variable_get(:@secret)
|
32
|
+
@tampered = JSON.parse(@valid_cipher)
|
33
|
+
@tampered['auth_tag'] = Base64.strict_encode64('tampered' * 4)
|
34
|
+
@model.instance_variable_set(:@secret, @tampered.to_json)
|
35
|
+
|
36
|
+
@model.secret
|
37
|
+
#=!> Familia::EncryptionError
|
38
|
+
#==> error.message.include?('Invalid encrypted data')
|
39
|
+
|
40
|
+
## Missing encryption config raises on validation
|
41
|
+
@original_keys = Familia.config.encryption_keys
|
42
|
+
Familia.config.encryption_keys = nil
|
43
|
+
Familia::Encryption.validate_configuration!
|
44
|
+
Familia.config.encryption_keys = @original_keys
|
45
|
+
#=!> Familia::EncryptionError
|
46
|
+
#==> error.message.include?('No encryption keys configured')
|
47
|
+
|
48
|
+
## Invalid Base64 in stored data causes decryption failure
|
49
|
+
# Reset keys for this test since they were cleared in previous test
|
50
|
+
Familia.config.encryption_keys = @test_keys
|
51
|
+
@model.instance_variable_set(:@secret, '{"algorithm":"aes-256-gcm","nonce":"!!!invalid!!!","ciphertext":"test","auth_tag":"test","key_version":"v1"}')
|
52
|
+
@model.secret
|
53
|
+
#=!> Familia::EncryptionError
|
54
|
+
#==> error.message.include?('Decryption failed')
|
55
|
+
|
56
|
+
## Derivation counter still increments on decryption errors
|
57
|
+
Familia::Encryption.reset_derivation_count!
|
58
|
+
# Ensure keys are available for this test
|
59
|
+
Familia.config.encryption_keys = @test_keys
|
60
|
+
@error_model = ErrorTest.new(id: 'err-counter')
|
61
|
+
# Set valid JSON but with invalid base64 data to trigger decrypt failure after parsing
|
62
|
+
@error_model.instance_variable_set(:@secret, '{"algorithm":"aes-256-gcm","nonce":"dGVzdA==","ciphertext":"invalid-base64!!!","auth_tag":"dGVzdA==","key_version":"v1"}')
|
63
|
+
begin
|
64
|
+
@error_model.secret
|
65
|
+
rescue
|
66
|
+
end
|
67
|
+
# Derivation attempted even if decrypt fails
|
68
|
+
Familia::Encryption.derivation_count.value
|
69
|
+
#=> 1
|
70
|
+
|
71
|
+
## Unsupported algorithm in encrypted data
|
72
|
+
# Ensure keys are available for this test
|
73
|
+
Familia.config.encryption_keys = @test_keys
|
74
|
+
@model.secret = 'test-data'
|
75
|
+
@cipher_data = JSON.parse(@model.instance_variable_get(:@secret))
|
76
|
+
@cipher_data['algorithm'] = 'unsupported-algorithm'
|
77
|
+
@model.instance_variable_set(:@secret, @cipher_data.to_json)
|
78
|
+
|
79
|
+
@model.secret
|
80
|
+
#=!> Familia::EncryptionError
|
81
|
+
#==> error.message.include?('Unsupported algorithm')
|
82
|
+
|
83
|
+
## Missing current key version causes validation error
|
84
|
+
@original_version = Familia.config.current_key_version
|
85
|
+
Familia.config.current_key_version = nil
|
86
|
+
Familia::Encryption.validate_configuration!
|
87
|
+
Familia.config.current_key_version = @original_version
|
88
|
+
#=!> Familia::EncryptionError
|
89
|
+
#==> error.message.include?('No current key version set')
|
90
|
+
|
91
|
+
## Invalid key version in encrypted data
|
92
|
+
# Ensure keys are available for this test
|
93
|
+
Familia.config.encryption_keys = @test_keys
|
94
|
+
Familia.config.current_key_version = :v1
|
95
|
+
@model.secret = 'test-data'
|
96
|
+
@cipher_with_bad_version = JSON.parse(@model.instance_variable_get(:@secret))
|
97
|
+
@cipher_with_bad_version['key_version'] = 'nonexistent'
|
98
|
+
@model.instance_variable_set(:@secret, @cipher_with_bad_version.to_json)
|
99
|
+
@model.secret
|
100
|
+
#=!> Familia::EncryptionError
|
101
|
+
#==> error.message.include?('No key for version: nonexistent')
|
102
|
+
|
103
|
+
## Empty string and nil values don't trigger encryption errors
|
104
|
+
@empty_model = ErrorTest.new(id: 'empty-test')
|
105
|
+
@empty_model.secret = ''
|
106
|
+
@empty_model.secret
|
107
|
+
#=> nil
|
108
|
+
|
109
|
+
## Nil assignment and retrieval
|
110
|
+
@empty_model.secret = nil
|
111
|
+
@empty_model.secret
|
112
|
+
#=> nil
|
113
|
+
|
114
|
+
# Cleanup
|
115
|
+
Familia.config.encryption_keys = nil
|
116
|
+
Familia.config.current_key_version = nil
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# try/features/encryption_fields/fresh_key_derivation_try.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
require_relative '../../helpers/test_helpers'
|
6
|
+
|
7
|
+
test_keys = {
|
8
|
+
v1: Base64.strict_encode64('a' * 32),
|
9
|
+
v2: Base64.strict_encode64('b' * 32)
|
10
|
+
}
|
11
|
+
Familia.config.encryption_keys = test_keys
|
12
|
+
Familia.config.current_key_version = :v1
|
13
|
+
|
14
|
+
class FreshKeyDerivationTest < Familia::Horreum
|
15
|
+
feature :encrypted_fields
|
16
|
+
identifier_field :user_id
|
17
|
+
field :user_id
|
18
|
+
encrypted_field :test_field
|
19
|
+
end
|
20
|
+
|
21
|
+
## Single encrypt operation increments counter
|
22
|
+
Familia::Encryption.reset_derivation_count!
|
23
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-encrypt-1')
|
24
|
+
model.test_field = 'test-value'
|
25
|
+
Familia::Encryption.derivation_count.value
|
26
|
+
#=> 1
|
27
|
+
|
28
|
+
## Single decrypt operation increments counter again
|
29
|
+
Familia::Encryption.reset_derivation_count!
|
30
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-decrypt-1')
|
31
|
+
model.test_field = 'test-value' # encrypt (1 derivation)
|
32
|
+
retrieved = model.test_field # decrypt (2 derivations)
|
33
|
+
[retrieved, Familia::Encryption.derivation_count.value]
|
34
|
+
#=> ['test-value', 2]
|
35
|
+
|
36
|
+
## Multiple encrypt operations accumulate derivation calls
|
37
|
+
Familia::Encryption.reset_derivation_count!
|
38
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-encrypt-multi')
|
39
|
+
3.times { |i| model.test_field = "value-#{i}" }
|
40
|
+
Familia::Encryption.derivation_count.value
|
41
|
+
#=> 3
|
42
|
+
|
43
|
+
## Multiple decrypt operations call derivation each time
|
44
|
+
Familia::Encryption.reset_derivation_count!
|
45
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-decrypt-multi')
|
46
|
+
model.test_field = 'initial-value'
|
47
|
+
3.times { model.test_field }
|
48
|
+
Familia::Encryption.derivation_count.value
|
49
|
+
#=> 4
|
50
|
+
|
51
|
+
## Mixed encrypt/decrypt operations accumulate calls
|
52
|
+
Familia::Encryption.reset_derivation_count!
|
53
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-mixed')
|
54
|
+
2.times { |i| model.test_field = "mixed-#{i}" } # 2 encryptions
|
55
|
+
2.times { model.test_field } # 2 decryptions
|
56
|
+
Familia::Encryption.derivation_count.value
|
57
|
+
#=> 4
|
58
|
+
|
59
|
+
## Write-read pairs trigger derivation for each operation
|
60
|
+
Familia::Encryption.reset_derivation_count!
|
61
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-pairs')
|
62
|
+
results = []
|
63
|
+
5.times do |i|
|
64
|
+
model.test_field = "pair-#{i}" # encrypt
|
65
|
+
results << model.test_field # decrypt
|
66
|
+
end
|
67
|
+
[results.length, Familia::Encryption.derivation_count.value]
|
68
|
+
#=> [5, 10]
|
69
|
+
|
70
|
+
## Different field values trigger fresh derivation each time
|
71
|
+
Familia::Encryption.reset_derivation_count!
|
72
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-different-values')
|
73
|
+
model.test_field = 'first'
|
74
|
+
first_count = Familia::Encryption.derivation_count.value
|
75
|
+
model.test_field = 'second'
|
76
|
+
second_count = Familia::Encryption.derivation_count.value
|
77
|
+
model.test_field = 'third'
|
78
|
+
third_count = Familia::Encryption.derivation_count.value
|
79
|
+
[first_count, second_count, third_count]
|
80
|
+
#=> [1, 2, 3]
|
81
|
+
|
82
|
+
## Verify no caching occurs across operations
|
83
|
+
Familia::Encryption.reset_derivation_count!
|
84
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-no-cache')
|
85
|
+
values = ['alpha', 'beta', 'gamma']
|
86
|
+
operation_pairs = values.map do |val|
|
87
|
+
model.test_field = val # encrypt
|
88
|
+
retrieved = model.test_field # decrypt
|
89
|
+
[val, retrieved]
|
90
|
+
end
|
91
|
+
all_match = operation_pairs.all? { |pair| pair[0] == pair[1] }
|
92
|
+
[all_match, Familia::Encryption.derivation_count.value]
|
93
|
+
#=> [true, 6]
|
94
|
+
|
95
|
+
## Empty string handling doesn't trigger derivation
|
96
|
+
Familia::Encryption.reset_derivation_count!
|
97
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-empty')
|
98
|
+
model.test_field = ''
|
99
|
+
empty_result = model.test_field
|
100
|
+
[empty_result, Familia::Encryption.derivation_count.value]
|
101
|
+
#=> [nil, 0]
|
102
|
+
|
103
|
+
## Nil values don't trigger derivation
|
104
|
+
Familia::Encryption.reset_derivation_count!
|
105
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-nil')
|
106
|
+
model.test_field = nil
|
107
|
+
nil_result = model.test_field
|
108
|
+
[nil_result, Familia::Encryption.derivation_count.value]
|
109
|
+
#=> [nil, 0]
|
110
|
+
|
111
|
+
## Key version rotation increments derivation count
|
112
|
+
Familia::Encryption.reset_derivation_count!
|
113
|
+
model = FreshKeyDerivationTest.new(user_id: 'test-rotation')
|
114
|
+
model.test_field = 'original' # v1 encrypt
|
115
|
+
Familia.config.current_key_version = :v2
|
116
|
+
model.test_field = 'updated' # v2 encrypt
|
117
|
+
retrieved = model.test_field # v2 decrypt
|
118
|
+
Familia::Encryption.derivation_count.value
|
119
|
+
#=> 3
|
120
|
+
|
121
|
+
Familia.config.encryption_keys = nil
|
122
|
+
Familia.config.current_key_version = nil
|
@@ -0,0 +1,163 @@
|
|
1
|
+
require_relative '../../helpers/test_helpers'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
# Setup encryption configuration
|
5
|
+
@test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
6
|
+
Familia.config.encryption_keys = @test_keys
|
7
|
+
Familia.config.current_key_version = :v1
|
8
|
+
|
9
|
+
class BasicEncryptedModel < Familia::Horreum
|
10
|
+
feature :encrypted_fields
|
11
|
+
identifier_field :user_id
|
12
|
+
field :user_id
|
13
|
+
encrypted_field :secret_data
|
14
|
+
end
|
15
|
+
|
16
|
+
class MultiInstanceModel < Familia::Horreum
|
17
|
+
feature :encrypted_fields
|
18
|
+
identifier_field :user_id
|
19
|
+
field :user_id
|
20
|
+
encrypted_field :data
|
21
|
+
end
|
22
|
+
|
23
|
+
class NonceTestModel < Familia::Horreum
|
24
|
+
feature :encrypted_fields
|
25
|
+
identifier_field :user_id
|
26
|
+
field :user_id
|
27
|
+
encrypted_field :repeatable_data
|
28
|
+
end
|
29
|
+
|
30
|
+
class TimingTestModel < Familia::Horreum
|
31
|
+
feature :encrypted_fields
|
32
|
+
identifier_field :user_id
|
33
|
+
field :user_id
|
34
|
+
encrypted_field :timed_data
|
35
|
+
end
|
36
|
+
|
37
|
+
class MultiFieldModel < Familia::Horreum
|
38
|
+
feature :encrypted_fields
|
39
|
+
identifier_field :user_id
|
40
|
+
field :user_id
|
41
|
+
encrypted_field :field_a
|
42
|
+
encrypted_field :field_b
|
43
|
+
end
|
44
|
+
|
45
|
+
class AADTestModel < Familia::Horreum
|
46
|
+
feature :encrypted_fields
|
47
|
+
identifier_field :user_id
|
48
|
+
field :user_id
|
49
|
+
field :context_field
|
50
|
+
encrypted_field :aad_protected, aad_fields: [:context_field]
|
51
|
+
end
|
52
|
+
|
53
|
+
class NilTestModel < Familia::Horreum
|
54
|
+
feature :encrypted_fields
|
55
|
+
identifier_field :user_id
|
56
|
+
field :user_id
|
57
|
+
encrypted_field :optional_data
|
58
|
+
end
|
59
|
+
|
60
|
+
class PersistenceTestModel < Familia::Horreum
|
61
|
+
feature :encrypted_fields
|
62
|
+
identifier_field :user_id
|
63
|
+
field :user_id
|
64
|
+
encrypted_field :persistent_data
|
65
|
+
end
|
66
|
+
|
67
|
+
## Basic encrypted field functionality works
|
68
|
+
model = BasicEncryptedModel.new(user_id: 'test-basic')
|
69
|
+
model.secret_data = 'confidential'
|
70
|
+
model.secret_data
|
71
|
+
#=> 'confidential'
|
72
|
+
|
73
|
+
## Different instances derive keys independently
|
74
|
+
model1 = MultiInstanceModel.new(user_id: 'user-1')
|
75
|
+
model2 = MultiInstanceModel.new(user_id: 'user-2')
|
76
|
+
model1.data = 'secret-1'
|
77
|
+
model2.data = 'secret-2'
|
78
|
+
[model1.data, model2.data]
|
79
|
+
#=> ['secret-1', 'secret-2']
|
80
|
+
|
81
|
+
## Same value encrypted multiple times produces different ciphertext
|
82
|
+
model = NonceTestModel.new(user_id: 'nonce-test')
|
83
|
+
model.repeatable_data = 'same-value'
|
84
|
+
first_internal = model.instance_variable_get(:@repeatable_data)
|
85
|
+
model.repeatable_data = 'same-value'
|
86
|
+
second_internal = model.instance_variable_get(:@repeatable_data)
|
87
|
+
first_internal != second_internal
|
88
|
+
#=> true
|
89
|
+
|
90
|
+
## Decrypted values remain the same despite different internal storage
|
91
|
+
model = NonceTestModel.new(user_id: 'nonce-test-2')
|
92
|
+
model.repeatable_data = 'same-value'
|
93
|
+
model.repeatable_data
|
94
|
+
#=> 'same-value'
|
95
|
+
|
96
|
+
## Fresh derivation verification through timing side-channel
|
97
|
+
@model = TimingTestModel.new(user_id: 'timing-test')
|
98
|
+
times = []
|
99
|
+
10.times do |i|
|
100
|
+
start_time = Time.now
|
101
|
+
@model.timed_data = "test-value-#{i}"
|
102
|
+
@model.timed_data
|
103
|
+
times << (Time.now - start_time)
|
104
|
+
end
|
105
|
+
min_time = times.min
|
106
|
+
max_time = times.max
|
107
|
+
variance_ratio = max_time / min_time
|
108
|
+
variance_ratio < 3.0
|
109
|
+
#=> true
|
110
|
+
|
111
|
+
## No cross-contamination between different field contexts
|
112
|
+
model = MultiFieldModel.new(user_id: 'multi-field')
|
113
|
+
model.field_a = 'value-a'
|
114
|
+
model.field_b = 'value-b'
|
115
|
+
internal_a = model.instance_variable_get(:@field_a)
|
116
|
+
internal_b = model.instance_variable_get(:@field_b)
|
117
|
+
internal_a != internal_b
|
118
|
+
#=> true
|
119
|
+
|
120
|
+
## Decrypted values are correct for multiple fields
|
121
|
+
model = MultiFieldModel.new(user_id: 'multi-field-2')
|
122
|
+
model.field_a = 'value-a'
|
123
|
+
model.field_b = 'value-b'
|
124
|
+
[model.field_a, model.field_b]
|
125
|
+
#=> ['value-a', 'value-b']
|
126
|
+
|
127
|
+
## AAD fields affect derivation context
|
128
|
+
model1 = AADTestModel.new(user_id: 'aad-test-1', context_field: 'context-a')
|
129
|
+
model2 = AADTestModel.new(user_id: 'aad-test-2', context_field: 'context-b')
|
130
|
+
model1.aad_protected = 'protected-data'
|
131
|
+
model2.aad_protected = 'protected-data'
|
132
|
+
internal1 = model1.instance_variable_get(:@aad_protected)
|
133
|
+
internal2 = model2.instance_variable_get(:@aad_protected)
|
134
|
+
internal1 != internal2
|
135
|
+
#=> true
|
136
|
+
|
137
|
+
## AAD protected fields decrypt correctly
|
138
|
+
model1 = AADTestModel.new(user_id: 'aad-test-3', context_field: 'context-a')
|
139
|
+
model2 = AADTestModel.new(user_id: 'aad-test-4', context_field: 'context-b')
|
140
|
+
model1.aad_protected = 'protected-data'
|
141
|
+
model2.aad_protected = 'protected-data'
|
142
|
+
[model1.aad_protected, model2.aad_protected]
|
143
|
+
#=> ['protected-data', 'protected-data']
|
144
|
+
|
145
|
+
## Memory efficiency - nil values not encrypted
|
146
|
+
model = NilTestModel.new(user_id: 'nil-test')
|
147
|
+
model.optional_data = nil
|
148
|
+
model.instance_variable_get(:@optional_data)
|
149
|
+
#=> nil
|
150
|
+
|
151
|
+
## Empty string should be encrypted differently than nil
|
152
|
+
model = NilTestModel.new(user_id: 'nil-test-2')
|
153
|
+
model.optional_data = ''
|
154
|
+
internal_empty = model.instance_variable_get(:@optional_data)
|
155
|
+
internal_empty.nil?
|
156
|
+
#=> true
|
157
|
+
|
158
|
+
## Consistent behavior across Ruby restart simulation
|
159
|
+
model = PersistenceTestModel.new(user_id: 'persistence-test')
|
160
|
+
model.persistent_data = 'data-to-persist'
|
161
|
+
Thread.current[:familia_request_cache] = nil if Thread.current[:familia_request_cache]
|
162
|
+
model.persistent_data
|
163
|
+
#=> 'data-to-persist'
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# try/features/encryption_fields/key_rotation_try.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
require_relative '../../helpers/test_helpers'
|
6
|
+
|
7
|
+
# Setup multiple key versions for rotation testing
|
8
|
+
@test_keys = {
|
9
|
+
v1: Base64.strict_encode64('a' * 32),
|
10
|
+
v2: Base64.strict_encode64('b' * 32),
|
11
|
+
v3: Base64.strict_encode64('c' * 32)
|
12
|
+
}
|
13
|
+
|
14
|
+
class RotationTest < Familia::Horreum
|
15
|
+
feature :encrypted_fields
|
16
|
+
identifier_field :id
|
17
|
+
field :id
|
18
|
+
encrypted_field :secret
|
19
|
+
end
|
20
|
+
|
21
|
+
# Data encrypted with v1 can still be decrypted after rotation to v2
|
22
|
+
Familia.config.encryption_keys = { v1: @test_keys[:v1] }
|
23
|
+
Familia.config.current_key_version = :v1
|
24
|
+
|
25
|
+
@model = RotationTest.new(id: 'rot-1')
|
26
|
+
@model.secret = 'original-secret'
|
27
|
+
@v1_ciphertext = @model.instance_variable_get(:@secret)
|
28
|
+
|
29
|
+
# Rotate to v2 with both keys available
|
30
|
+
Familia.config.encryption_keys = { v1: @test_keys[:v1], v2: @test_keys[:v2] }
|
31
|
+
Familia.config.current_key_version = :v2
|
32
|
+
|
33
|
+
## Manually set the old ciphertext and try to decrypt
|
34
|
+
@model.instance_variable_set(:@secret, @v1_ciphertext)
|
35
|
+
@model.secret
|
36
|
+
#=> 'original-secret'
|
37
|
+
|
38
|
+
## New data encrypts with current key version (v2)
|
39
|
+
@model.secret = 'updated-secret'
|
40
|
+
@v2_ciphertext = @model.instance_variable_get(:@secret)
|
41
|
+
@parsed_v2 = JSON.parse(@v2_ciphertext, symbolize_names: true)
|
42
|
+
@parsed_v2[:key_version]
|
43
|
+
#=> "v2"
|
44
|
+
|
45
|
+
## Missing historical key causes decryption failure
|
46
|
+
Familia.config.encryption_keys = { v3: @test_keys[:v3] }
|
47
|
+
Familia.config.current_key_version = :v3
|
48
|
+
@model.instance_variable_set(:@secret, @v1_ciphertext)
|
49
|
+
begin
|
50
|
+
@model.secret
|
51
|
+
false
|
52
|
+
rescue Familia::EncryptionError => e
|
53
|
+
e.message.include?('No key for version: v1')
|
54
|
+
end
|
55
|
+
#=> true
|
56
|
+
|
57
|
+
## Derivation counter increments during key rotation operations
|
58
|
+
Familia::Encryption.reset_derivation_count!
|
59
|
+
Familia.config.encryption_keys = @test_keys
|
60
|
+
Familia.config.current_key_version = :v1
|
61
|
+
#=> :v1
|
62
|
+
|
63
|
+
## Derivation counter increments
|
64
|
+
@rotation_model = RotationTest.new(id: 'rot-counter')
|
65
|
+
@rotation_model.secret = 'test1' # v1 encrypt
|
66
|
+
Familia::Encryption.derivation_count.value
|
67
|
+
#=> 1
|
68
|
+
|
69
|
+
## Key rotation to v2 for new encryption
|
70
|
+
Familia.config.current_key_version = :v2
|
71
|
+
@rotation_model.secret = 'test2' # v2 encrypt
|
72
|
+
Familia::Encryption.derivation_count.value
|
73
|
+
#=> 2
|
74
|
+
|
75
|
+
## Decryption with v2 key
|
76
|
+
@retrieved = @rotation_model.secret # v2 decrypt
|
77
|
+
Familia::Encryption.derivation_count.value
|
78
|
+
#=> 3
|
79
|
+
|
80
|
+
## Key rotation to v3 for new encryption
|
81
|
+
Familia.config.current_key_version = :v3
|
82
|
+
@rotation_model.secret = 'test3' # v3 encrypt
|
83
|
+
Familia::Encryption.derivation_count.value
|
84
|
+
#=> 4
|
85
|
+
|
86
|
+
## Multiple key versions coexist for backward compatibility
|
87
|
+
Familia.config.encryption_keys = { v1: @test_keys[:v1], v2: @test_keys[:v2], v3: @test_keys[:v3] }
|
88
|
+
Familia.config.current_key_version = :v2
|
89
|
+
|
90
|
+
@multi_model = RotationTest.new(id: 'multi-key')
|
91
|
+
|
92
|
+
# Create data with v1
|
93
|
+
Familia.config.current_key_version = :v1
|
94
|
+
@multi_model.secret = 'v1-data'
|
95
|
+
@v1_data = @multi_model.instance_variable_get(:@secret)
|
96
|
+
|
97
|
+
# Create data with v3
|
98
|
+
Familia.config.current_key_version = :v3
|
99
|
+
@multi_model.secret = 'v3-data'
|
100
|
+
@v3_data = @multi_model.instance_variable_get(:@secret)
|
101
|
+
|
102
|
+
# Switch back to v2 as current
|
103
|
+
Familia.config.current_key_version = :v2
|
104
|
+
|
105
|
+
# Can still decrypt v1 data
|
106
|
+
@multi_model.instance_variable_set(:@secret, @v1_data)
|
107
|
+
@multi_model.secret
|
108
|
+
#=> 'v1-data'
|
109
|
+
|
110
|
+
## Can still decrypt v3 data with v2 as current key
|
111
|
+
@multi_model.instance_variable_set(:@secret, @v3_data)
|
112
|
+
@multi_model.secret
|
113
|
+
#=> 'v3-data'
|
114
|
+
|
115
|
+
# Cleanup
|
116
|
+
Familia.config.encryption_keys = nil
|
117
|
+
Familia.config.current_key_version = nil
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# try/features/encryption_fields/memory_security_try.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require_relative '../../helpers/test_helpers'
|
5
|
+
|
6
|
+
test_keys = {
|
7
|
+
v1: Base64.strict_encode64('a' * 32),
|
8
|
+
}
|
9
|
+
Familia.config.encryption_keys = test_keys
|
10
|
+
Familia.config.current_key_version = :v1
|
11
|
+
|
12
|
+
## Keys are wiped from memory after use
|
13
|
+
# Note: This is difficult to test directly, but we can verify
|
14
|
+
# the secure_wipe method is called
|
15
|
+
|
16
|
+
class WipeTest < Familia::Horreum
|
17
|
+
feature :encrypted_fields
|
18
|
+
identifier_field :id
|
19
|
+
field :id
|
20
|
+
encrypted_field :secret
|
21
|
+
end
|
22
|
+
|
23
|
+
# Monkey-patch to track wipe calls
|
24
|
+
wipe_calls = 0
|
25
|
+
original_wipe = Familia::Encryption.singleton_method(:secure_wipe)
|
26
|
+
Familia::Encryption.define_singleton_method(:secure_wipe) do |key|
|
27
|
+
wipe_calls += 1
|
28
|
+
original_wipe.call(key)
|
29
|
+
end
|
30
|
+
|
31
|
+
model = WipeTest.new(id: 'wipe-1')
|
32
|
+
model.secret = 'test'
|
33
|
+
model.secret
|
34
|
+
|
35
|
+
# Should wipe master key after each derivation (2 operations = 2 wipes)
|
36
|
+
wipe_calls >= 2
|
37
|
+
#=> true
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# try/features/encryption_fields/missing_current_key_version_try.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
require_relative '../../helpers/test_helpers'
|
6
|
+
|
7
|
+
# This tryouts file is based on the premise that there is no current key
|
8
|
+
# version set. This is a global setting so if other tryouts rely on
|
9
|
+
# having it, they will fail unless they set it for themselves.
|
10
|
+
Familia.config.current_key_version = nil
|
11
|
+
|
12
|
+
class NoCurrentKeyVersionTest < Familia::Horreum
|
13
|
+
feature :encrypted_fields
|
14
|
+
identifier_field :user_id
|
15
|
+
field :user_id
|
16
|
+
encrypted_field :test_field
|
17
|
+
end
|
18
|
+
|
19
|
+
## Attempt to encrypt will raise an encryption error
|
20
|
+
model_student = NoCurrentKeyVersionTest.new(user_id: 'derivation-test')
|
21
|
+
model_student.test_field = 'test-value'
|
22
|
+
#=!> Familia::EncryptionError
|
23
|
+
#=!> error.message.include?('Key version cannot be nil')
|