familia 2.0.0.pre4 → 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 -243
- 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/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,169 @@
|
|
1
|
+
# try/encryption/providers/xchacha20_poly1305_provider_try.rb
|
2
|
+
|
3
|
+
require_relative '../../helpers/test_helpers'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
## XChaCha20Poly1305 provider availability check
|
7
|
+
Familia::Encryption::Providers::XChaCha20Poly1305Provider.available?
|
8
|
+
#=> true
|
9
|
+
|
10
|
+
## XChaCha20Poly1305 provider initialization
|
11
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
12
|
+
[provider.algorithm, provider.nonce_size, provider.auth_tag_size]
|
13
|
+
#=> ['xchacha20poly1305', 24, 16]
|
14
|
+
|
15
|
+
## XChaCha20Poly1305 provider priority is highest
|
16
|
+
Familia::Encryption::Providers::XChaCha20Poly1305Provider.priority
|
17
|
+
#=> 100
|
18
|
+
|
19
|
+
## XChaCha20Poly1305 nonce generation produces correct size
|
20
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
21
|
+
nonce = provider.generate_nonce
|
22
|
+
nonce.bytesize
|
23
|
+
#=> 24
|
24
|
+
|
25
|
+
## XChaCha20Poly1305 key derivation with default personalization
|
26
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
27
|
+
Familia.config.encryption_keys = test_keys
|
28
|
+
Familia.config.current_key_version = :v1
|
29
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
30
|
+
master_key = Base64.strict_decode64(test_keys[:v1])
|
31
|
+
context = 'test-context'
|
32
|
+
derived_key = provider.derive_key(master_key, context)
|
33
|
+
derived_key.bytesize
|
34
|
+
#=> 32
|
35
|
+
|
36
|
+
## XChaCha20Poly1305 key derivation with custom personalization
|
37
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
38
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
39
|
+
master_key = Base64.strict_decode64(test_keys[:v1])
|
40
|
+
context = 'test-context'
|
41
|
+
derived_key1 = provider.derive_key(master_key, context, personal: 'custom1')
|
42
|
+
derived_key2 = provider.derive_key(master_key, context, personal: 'custom2')
|
43
|
+
derived_key1 != derived_key2
|
44
|
+
#=> true
|
45
|
+
|
46
|
+
## XChaCha20Poly1305 key derivation rejects null bytes in personalization
|
47
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
48
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
49
|
+
master_key = Base64.strict_decode64(test_keys[:v1])
|
50
|
+
context = 'test-context'
|
51
|
+
provider.derive_key(master_key, context, personal: "bad\0personal")
|
52
|
+
#=!> Familia::EncryptionError
|
53
|
+
#==> error.message.include?('null bytes')
|
54
|
+
|
55
|
+
## XChaCha20Poly1305 encryption produces expected structure
|
56
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
57
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
58
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
59
|
+
plaintext = 'test encryption data'
|
60
|
+
result = provider.encrypt(plaintext, key)
|
61
|
+
[result.has_key?(:nonce), result.has_key?(:ciphertext), result.has_key?(:auth_tag)]
|
62
|
+
#=> [true, true, true]
|
63
|
+
|
64
|
+
## XChaCha20Poly1305 encryption nonce is 24 bytes
|
65
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
66
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
67
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
68
|
+
plaintext = 'test encryption data'
|
69
|
+
result = provider.encrypt(plaintext, key)
|
70
|
+
result[:nonce].bytesize
|
71
|
+
#=> 24
|
72
|
+
|
73
|
+
## XChaCha20Poly1305 encryption auth tag is 16 bytes
|
74
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
75
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
76
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
77
|
+
plaintext = 'test encryption data'
|
78
|
+
result = provider.encrypt(plaintext, key)
|
79
|
+
result[:auth_tag].bytesize
|
80
|
+
#=> 16
|
81
|
+
|
82
|
+
## XChaCha20Poly1305 round-trip encryption/decryption
|
83
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
84
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
85
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
86
|
+
plaintext = 'XChaCha20Poly1305 round-trip test'
|
87
|
+
encrypted = provider.encrypt(plaintext, key)
|
88
|
+
decrypted = provider.decrypt(encrypted[:ciphertext], key, encrypted[:nonce], encrypted[:auth_tag])
|
89
|
+
decrypted
|
90
|
+
#=> 'XChaCha20Poly1305 round-trip test'
|
91
|
+
|
92
|
+
## XChaCha20Poly1305 encryption with AAD (Additional Authenticated Data)
|
93
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
94
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
95
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
96
|
+
plaintext = 'test with aad'
|
97
|
+
aad = 'additional-authenticated-data'
|
98
|
+
encrypted = provider.encrypt(plaintext, key, aad)
|
99
|
+
decrypted = provider.decrypt(encrypted[:ciphertext], key, encrypted[:nonce], encrypted[:auth_tag], aad)
|
100
|
+
decrypted
|
101
|
+
#=> 'test with aad'
|
102
|
+
|
103
|
+
## XChaCha20Poly1305 AAD tampering fails authentication
|
104
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
105
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
106
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
107
|
+
plaintext = 'test with aad'
|
108
|
+
aad = 'original-aad'
|
109
|
+
encrypted = provider.encrypt(plaintext, key, aad)
|
110
|
+
provider.decrypt(encrypted[:ciphertext], key, encrypted[:nonce], encrypted[:auth_tag], 'tampered-aad')
|
111
|
+
#=!> Familia::EncryptionError
|
112
|
+
#==> error.message.include?('Decryption failed')
|
113
|
+
|
114
|
+
## XChaCha20Poly1305 nonce tampering fails authentication
|
115
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
116
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
117
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
118
|
+
plaintext = 'test nonce tampering'
|
119
|
+
encrypted = provider.encrypt(plaintext, key)
|
120
|
+
tampered_nonce = 'x' * 24 # Wrong nonce
|
121
|
+
provider.decrypt(encrypted[:ciphertext], key, tampered_nonce, encrypted[:auth_tag])
|
122
|
+
#=!> Familia::EncryptionError
|
123
|
+
#==> error.message.include?('Decryption failed')
|
124
|
+
|
125
|
+
## XChaCha20Poly1305 auth tag tampering fails authentication
|
126
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
127
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
128
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
129
|
+
plaintext = 'test auth tag tampering'
|
130
|
+
encrypted = provider.encrypt(plaintext, key)
|
131
|
+
tampered_auth_tag = 'y' * 16 # Wrong auth tag
|
132
|
+
provider.decrypt(encrypted[:ciphertext], key, encrypted[:nonce], tampered_auth_tag)
|
133
|
+
#=!> Familia::EncryptionError
|
134
|
+
#==> error.message.include?('Decryption failed')
|
135
|
+
|
136
|
+
## XChaCha20Poly1305 ciphertext tampering fails authentication
|
137
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
138
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
139
|
+
key = Base64.strict_decode64(test_keys[:v1])
|
140
|
+
plaintext = 'test ciphertext tampering'
|
141
|
+
encrypted = provider.encrypt(plaintext, key)
|
142
|
+
tampered_ciphertext = encrypted[:ciphertext][0..-2] + 'X' # Change last byte
|
143
|
+
provider.decrypt(tampered_ciphertext, key, encrypted[:nonce], encrypted[:auth_tag])
|
144
|
+
#=!> Familia::EncryptionError
|
145
|
+
#==> error.message.include?('Decryption failed')
|
146
|
+
|
147
|
+
## XChaCha20Poly1305 key validation rejects nil key
|
148
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
149
|
+
provider.encrypt('test', nil)
|
150
|
+
#=!> Familia::EncryptionError
|
151
|
+
#==> error.message.include?('Key cannot be nil')
|
152
|
+
|
153
|
+
## XChaCha20Poly1305 key validation requires 32-byte minimum
|
154
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
155
|
+
short_key = 'x' * 16 # Only 16 bytes
|
156
|
+
provider.encrypt('test', short_key)
|
157
|
+
#=!> Familia::EncryptionError
|
158
|
+
#==> error.message.include?('Key must be at least 32 bytes')
|
159
|
+
|
160
|
+
## XChaCha20Poly1305 secure_wipe clears key (best effort)
|
161
|
+
provider = Familia::Encryption::Providers::XChaCha20Poly1305Provider.new
|
162
|
+
test_key = 'secret-key-data-to-be-wiped'
|
163
|
+
original_length = test_key.length
|
164
|
+
provider.secure_wipe(test_key)
|
165
|
+
test_key.length
|
166
|
+
#=> 0
|
167
|
+
|
168
|
+
# TEARDOWN
|
169
|
+
Thread.current[:familia_key_cache]&.clear if Thread.current[:familia_key_cache]
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# try/encryption/debug4_try.rb
|
2
|
+
|
3
|
+
# - Tests full encryption/decryption round trips
|
4
|
+
# - Validates that encrypted data can be successfully decrypted
|
5
|
+
|
6
|
+
require 'base64'
|
7
|
+
|
8
|
+
require_relative '../helpers/test_helpers'
|
9
|
+
|
10
|
+
## Test successful encryption
|
11
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
12
|
+
Familia.config.encryption_keys = test_keys
|
13
|
+
Familia.config.current_key_version = :v1
|
14
|
+
result = Familia::Encryption.encrypt('test', context: 'test')
|
15
|
+
result.class == String && result.length > 0
|
16
|
+
#=> true
|
17
|
+
|
18
|
+
## Test successful decryption
|
19
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
20
|
+
Familia.config.encryption_keys = test_keys
|
21
|
+
Familia.config.current_key_version = :v1
|
22
|
+
encrypted = Familia::Encryption.encrypt('test', context: 'test')
|
23
|
+
decrypted = Familia::Encryption.decrypt(encrypted, context: 'test')
|
24
|
+
decrypted
|
25
|
+
#=> 'test'
|
26
|
+
|
27
|
+
|
28
|
+
# TEARDOWN
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# try/encryption/secure_memory_handling_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
require_relative '../../lib/familia/encryption/providers/secure_xchacha20_poly1305_provider'
|
5
|
+
require 'base64'
|
6
|
+
|
7
|
+
# SETUP
|
8
|
+
Familia.config.encryption_keys = {
|
9
|
+
v1: Base64.strict_encode64('a' * 32)
|
10
|
+
}
|
11
|
+
Familia.config.current_key_version = :v1
|
12
|
+
|
13
|
+
## SecureXChaCha20Poly1305Provider is available when dependencies are loaded
|
14
|
+
@provider_class = Familia::Encryption::Providers::SecureXChaCha20Poly1305Provider
|
15
|
+
@provider_class.available?
|
16
|
+
#=> true
|
17
|
+
|
18
|
+
## Provider has higher priority than regular XChaCha20Poly1305Provider
|
19
|
+
@provider_class.priority > Familia::Encryption::Providers::XChaCha20Poly1305Provider.priority
|
20
|
+
#=> true
|
21
|
+
|
22
|
+
## secure_wipe clears key data from memory
|
23
|
+
provider = @provider_class.new
|
24
|
+
key = 'sensitive_key_data_here_' * 2 # 50 bytes
|
25
|
+
original_key = key.dup
|
26
|
+
provider.secure_wipe(key)
|
27
|
+
key.empty?
|
28
|
+
#=> true
|
29
|
+
|
30
|
+
## secure_wipe handles nil keys gracefully
|
31
|
+
provider = @provider_class.new
|
32
|
+
provider.secure_wipe(nil)
|
33
|
+
# Should not raise error
|
34
|
+
true
|
35
|
+
#=> true
|
36
|
+
|
37
|
+
## derive_key clears intermediate personalization data
|
38
|
+
provider = @provider_class.new
|
39
|
+
master_key = 'a' * 32
|
40
|
+
context = 'TestModel:field:user123'
|
41
|
+
|
42
|
+
# Create a test to verify personalization string gets cleared
|
43
|
+
# (We can't directly test this but we verify the function works)
|
44
|
+
derived_key = provider.derive_key(master_key, context, personal: 'test_personal')
|
45
|
+
derived_key.bytesize
|
46
|
+
#=> 32
|
47
|
+
|
48
|
+
## encrypt operation clears key after use (demonstration)
|
49
|
+
provider = @provider_class.new
|
50
|
+
master_key = ('a' * 32).dup # Make mutable copy
|
51
|
+
derived_key = provider.derive_key(master_key, 'test_context')
|
52
|
+
plaintext = 'sensitive data'
|
53
|
+
|
54
|
+
# Key will be cleared after encryption
|
55
|
+
encrypted_data = provider.encrypt(plaintext, derived_key)
|
56
|
+
encrypted_data.key?(:ciphertext) && encrypted_data.key?(:nonce) && encrypted_data.key?(:auth_tag)
|
57
|
+
#=> true
|
58
|
+
|
59
|
+
## decrypt operation clears key after use (demonstration)
|
60
|
+
provider = @provider_class.new
|
61
|
+
master_key = 'a' * 32
|
62
|
+
context = 'TestModel:field:user123'
|
63
|
+
derived_key = provider.derive_key(master_key, context)
|
64
|
+
plaintext = 'sensitive data'
|
65
|
+
|
66
|
+
# Encrypt first
|
67
|
+
encrypted_data = provider.encrypt(plaintext, derived_key.dup)
|
68
|
+
|
69
|
+
# Create fresh key for decryption (since original was cleared)
|
70
|
+
fresh_key = provider.derive_key(master_key, context)
|
71
|
+
decrypted = provider.decrypt(
|
72
|
+
encrypted_data[:ciphertext],
|
73
|
+
fresh_key,
|
74
|
+
encrypted_data[:nonce],
|
75
|
+
encrypted_data[:auth_tag]
|
76
|
+
)
|
77
|
+
decrypted
|
78
|
+
#=> "sensitive data"
|
79
|
+
|
80
|
+
## Key derivation with null byte validation still works
|
81
|
+
provider = @provider_class.new
|
82
|
+
master_key = 'a' * 32
|
83
|
+
context = 'TestModel:field:user123'
|
84
|
+
personal_with_null = "app\0version"
|
85
|
+
|
86
|
+
begin
|
87
|
+
provider.derive_key(master_key, context, personal: personal_with_null)
|
88
|
+
"should_not_reach_here"
|
89
|
+
rescue Familia::EncryptionError => e
|
90
|
+
e.message
|
91
|
+
end
|
92
|
+
#=> "Personalization string must not contain null bytes"
|
93
|
+
|
94
|
+
## Round-trip encryption/decryption works with secure provider
|
95
|
+
provider = @provider_class.new
|
96
|
+
master_key = 'a' * 32
|
97
|
+
context = 'TestModel:field:user123'
|
98
|
+
plaintext = 'sensitive data here'
|
99
|
+
|
100
|
+
# Derive keys separately since they get cleared after use
|
101
|
+
key_for_encrypt = provider.derive_key(master_key, context)
|
102
|
+
key_for_decrypt = provider.derive_key(master_key, context)
|
103
|
+
|
104
|
+
encrypted_data = provider.encrypt(plaintext, key_for_encrypt)
|
105
|
+
decrypted = provider.decrypt(
|
106
|
+
encrypted_data[:ciphertext],
|
107
|
+
key_for_decrypt,
|
108
|
+
encrypted_data[:nonce],
|
109
|
+
encrypted_data[:auth_tag]
|
110
|
+
)
|
111
|
+
decrypted
|
112
|
+
#=> "sensitive data here"
|
113
|
+
|
114
|
+
## Generate nonce produces correct size
|
115
|
+
provider = @provider_class.new
|
116
|
+
nonce = provider.generate_nonce
|
117
|
+
nonce.bytesize
|
118
|
+
#=> 24
|
119
|
+
|
120
|
+
## Provider algorithm identifier distinguishes it from regular provider
|
121
|
+
@provider_class.const_get(:ALGORITHM)
|
122
|
+
#=> "xchacha20poly1305-secure"
|
123
|
+
|
124
|
+
# TEARDOWN
|
125
|
+
Thread.current[:familia_key_cache]&.clear if Thread.current[:familia_key_cache]
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# try/features/encrypted_fields_core_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
|
7
|
+
## Encrypted field methods are properly defined
|
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 SecureUser < Familia::Horreum
|
13
|
+
feature :encrypted_fields
|
14
|
+
identifier_field :user_id
|
15
|
+
|
16
|
+
field :user_id
|
17
|
+
field :email
|
18
|
+
encrypted_field :ssn
|
19
|
+
encrypted_field :api_key, aad_fields: [:email]
|
20
|
+
end
|
21
|
+
|
22
|
+
user = SecureUser.new(user_id: 'test-user-001', email: 'test@example.com')
|
23
|
+
user.respond_to?(:ssn) && user.respond_to?(:ssn=)
|
24
|
+
#=> true
|
25
|
+
|
26
|
+
## Setting encrypted field stores ciphertext internally
|
27
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
28
|
+
Familia.config.encryption_keys = test_keys
|
29
|
+
Familia.config.current_key_version = :v1
|
30
|
+
|
31
|
+
class SecureUser2 < Familia::Horreum
|
32
|
+
feature :encrypted_fields
|
33
|
+
identifier_field :user_id
|
34
|
+
field :user_id
|
35
|
+
encrypted_field :ssn
|
36
|
+
end
|
37
|
+
|
38
|
+
user = SecureUser2.new(user_id: 'test-user-002')
|
39
|
+
user.ssn = '123-45-6789'
|
40
|
+
stored_value = user.instance_variable_get(:@ssn)
|
41
|
+
stored_value.is_a?(String) && stored_value.include?('algorithm')
|
42
|
+
#=> true
|
43
|
+
|
44
|
+
## Getter transparently decrypts the value
|
45
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
46
|
+
Familia.config.encryption_keys = test_keys
|
47
|
+
Familia.config.current_key_version = :v1
|
48
|
+
|
49
|
+
class SecureUserDecrypt < Familia::Horreum
|
50
|
+
feature :encrypted_fields
|
51
|
+
identifier_field :user_id
|
52
|
+
field :user_id
|
53
|
+
encrypted_field :ssn
|
54
|
+
end
|
55
|
+
|
56
|
+
user = SecureUserDecrypt.new(user_id: 'decrypt-test')
|
57
|
+
user.ssn = '123-45-6789'
|
58
|
+
user.ssn
|
59
|
+
#=> '123-45-6789'## Setting nil stores nil internally and returns nil
|
60
|
+
|
61
|
+
## repaired test
|
62
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
63
|
+
Familia.config.encryption_keys = test_keys
|
64
|
+
Familia.config.current_key_version = :v1
|
65
|
+
|
66
|
+
class SecureUser3 < Familia::Horreum
|
67
|
+
feature :encrypted_fields
|
68
|
+
identifier_field :user_id
|
69
|
+
field :user_id
|
70
|
+
encrypted_field :ssn
|
71
|
+
end
|
72
|
+
|
73
|
+
user = SecureUser3.new(user_id: 'test-user-003')
|
74
|
+
user.ssn = nil
|
75
|
+
result = user.instance_variable_get(:@ssn)
|
76
|
+
user.ssn.nil? && result.nil?
|
77
|
+
#=> true
|
78
|
+
|
79
|
+
## Field type is correctly identified as encrypted
|
80
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
81
|
+
Familia.config.encryption_keys = test_keys
|
82
|
+
Familia.config.current_key_version = :v1
|
83
|
+
|
84
|
+
class SecureUser4 < Familia::Horreum
|
85
|
+
feature :encrypted_fields
|
86
|
+
identifier_field :user_id
|
87
|
+
field :user_id
|
88
|
+
encrypted_field :ssn
|
89
|
+
end
|
90
|
+
|
91
|
+
field_type = SecureUser4.field_types[:ssn]
|
92
|
+
field_type.category
|
93
|
+
#=> :encrypted
|
94
|
+
|
95
|
+
## Field type is persistent
|
96
|
+
SecureUser4.field_types[:ssn].persistent?
|
97
|
+
#=> true
|
98
|
+
|
99
|
+
## Encrypted field with AAD fields configured
|
100
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
101
|
+
Familia.config.encryption_keys = test_keys
|
102
|
+
Familia.config.current_key_version = :v1
|
103
|
+
|
104
|
+
class SecureUser5 < Familia::Horreum
|
105
|
+
feature :encrypted_fields
|
106
|
+
identifier_field :user_id
|
107
|
+
field :user_id
|
108
|
+
field :email
|
109
|
+
encrypted_field :api_key, aad_fields: [:email]
|
110
|
+
end
|
111
|
+
|
112
|
+
user = SecureUser5.new(user_id: 'test-user-005', email: 'test@example.com')
|
113
|
+
user.api_key = 'secret-key-123'
|
114
|
+
user.api_key
|
115
|
+
#=> 'secret-key-123'
|
116
|
+
|
117
|
+
Thread.current[:familia_key_cache]&.clear if Thread.current[:familia_key_cache]
|
@@ -0,0 +1,220 @@
|
|
1
|
+
# try/features/encrypted_fields_integration_try.rb
|
2
|
+
|
3
|
+
# Test constants will be redefined in each test since variables don't persist
|
4
|
+
|
5
|
+
require_relative '../helpers/test_helpers'
|
6
|
+
require 'base64'
|
7
|
+
|
8
|
+
|
9
|
+
class FullSecureModel < Familia::Horreum
|
10
|
+
feature :encrypted_fields
|
11
|
+
identifier_field :model_id
|
12
|
+
|
13
|
+
field :model_id
|
14
|
+
field :name # Regular field
|
15
|
+
field :email # Regular field for AAD
|
16
|
+
encrypted_field :password # Encrypted without AAD
|
17
|
+
encrypted_field :api_token, aad_fields: [:email] # Encrypted with AAD
|
18
|
+
|
19
|
+
list :activity_log # Regular list
|
20
|
+
hashkey :metadata # Regular hashkey
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
## Full model initialization with mixed field types works
|
26
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32), v2: Base64.strict_encode64('b' * 32) }
|
27
|
+
Familia.config.encryption_keys = test_keys
|
28
|
+
Familia.config.current_key_version = :v2
|
29
|
+
|
30
|
+
model = FullSecureModel.new(
|
31
|
+
model_id: 'secure-123',
|
32
|
+
name: 'Test User',
|
33
|
+
email: 'test@secure.com'
|
34
|
+
)
|
35
|
+
[model.model_id, model.name, model.email]
|
36
|
+
#=> ['secure-123', 'Test User', 'test@secure.com']
|
37
|
+
|
38
|
+
## Setting encrypted fields works alongside regular fields
|
39
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32), v2: Base64.strict_encode64('b' * 32) }
|
40
|
+
Familia.config.encryption_keys = test_keys
|
41
|
+
Familia.config.current_key_version = :v2
|
42
|
+
|
43
|
+
class FullSecureModel2 < Familia::Horreum
|
44
|
+
feature :encrypted_fields
|
45
|
+
identifier_field :model_id
|
46
|
+
|
47
|
+
field :model_id
|
48
|
+
field :name
|
49
|
+
field :email
|
50
|
+
encrypted_field :password
|
51
|
+
encrypted_field :api_token, aad_fields: [:email]
|
52
|
+
end
|
53
|
+
|
54
|
+
model = FullSecureModel2.new(
|
55
|
+
model_id: 'secure-124',
|
56
|
+
name: 'Test User 2',
|
57
|
+
email: 'test2@secure.com'
|
58
|
+
)
|
59
|
+
model.password = 'secret-password-123'
|
60
|
+
model.api_token = 'api-token-abc-xyz'
|
61
|
+
[model.password, model.api_token]
|
62
|
+
#=> ['secret-password-123', 'api-token-abc-xyz']## Serialization via to_h includes plaintext (as expected for normal usage)
|
63
|
+
|
64
|
+
## repaired test
|
65
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
66
|
+
Familia.config.encryption_keys = test_keys
|
67
|
+
Familia.config.current_key_version = :v1
|
68
|
+
|
69
|
+
class FullSecureModel3 < Familia::Horreum
|
70
|
+
feature :encrypted_fields
|
71
|
+
identifier_field :model_id
|
72
|
+
|
73
|
+
field :model_id
|
74
|
+
encrypted_field :password
|
75
|
+
end
|
76
|
+
|
77
|
+
model = FullSecureModel3.new(model_id: 'secure-125')
|
78
|
+
model.password = 'secret-password-123'
|
79
|
+
hash_representation = model.to_h
|
80
|
+
# to_h calls getters, so it includes decrypted values
|
81
|
+
hash_representation.values.any? { |v| v.to_s.include?('secret-password-123') }
|
82
|
+
#=> true
|
83
|
+
|
84
|
+
## Instance variables contain encrypted data structure
|
85
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
86
|
+
Familia.config.encryption_keys = test_keys
|
87
|
+
Familia.config.current_key_version = :v1
|
88
|
+
|
89
|
+
class FullSecureModel3b < Familia::Horreum
|
90
|
+
feature :encrypted_fields
|
91
|
+
identifier_field :model_id
|
92
|
+
field :model_id
|
93
|
+
encrypted_field :password
|
94
|
+
end
|
95
|
+
|
96
|
+
model = FullSecureModel3b.new(model_id: 'secure-125b')
|
97
|
+
model.password = 'secret-password-123'
|
98
|
+
# Internal storage should be encrypted
|
99
|
+
encrypted_password = model.instance_variable_get(:@password)
|
100
|
+
encrypted_password.is_a?(String) && encrypted_password.include?('"algorithm":"xchacha20poly1305"')
|
101
|
+
#=> true
|
102
|
+
|
103
|
+
## Mixed data types work correctly with encrypted fields
|
104
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
105
|
+
Familia.config.encryption_keys = test_keys
|
106
|
+
Familia.config.current_key_version = :v1
|
107
|
+
|
108
|
+
class FullSecureModel4 < Familia::Horreum
|
109
|
+
feature :encrypted_fields
|
110
|
+
identifier_field :model_id
|
111
|
+
|
112
|
+
field :model_id
|
113
|
+
encrypted_field :password
|
114
|
+
list :activity_log
|
115
|
+
hashkey :metadata
|
116
|
+
end
|
117
|
+
|
118
|
+
model = FullSecureModel4.new(model_id: 'secure-126')
|
119
|
+
model.password = 'secure-pass'
|
120
|
+
model.activity_log << 'User logged in'
|
121
|
+
model.metadata['last_login'] = Time.now.to_i.to_s
|
122
|
+
|
123
|
+
[model.password, model.activity_log.size, model.metadata.has_key?('last_login')]
|
124
|
+
#=> ['secure-pass', 1, true]
|
125
|
+
|
126
|
+
## Provider-specific integration: XChaCha20Poly1305 encryption
|
127
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
128
|
+
Familia.config.encryption_keys = test_keys
|
129
|
+
Familia.config.current_key_version = :v1
|
130
|
+
|
131
|
+
class XChaChaIntegrationModel < Familia::Horreum
|
132
|
+
feature :encrypted_fields
|
133
|
+
identifier_field :model_id
|
134
|
+
|
135
|
+
field :model_id
|
136
|
+
encrypted_field :secret_data
|
137
|
+
end
|
138
|
+
|
139
|
+
xchacha_model = XChaChaIntegrationModel.new(model_id: 'xchacha-test')
|
140
|
+
xchacha_model.secret_data = 'xchacha20poly1305 integration test'
|
141
|
+
|
142
|
+
# Verify XChaCha20Poly1305 is used by default
|
143
|
+
encrypted_data = xchacha_model.instance_variable_get(:@secret_data)
|
144
|
+
parsed_data = JSON.parse(encrypted_data, symbolize_names: true)
|
145
|
+
parsed_data[:algorithm]
|
146
|
+
#=> "xchacha20poly1305"
|
147
|
+
|
148
|
+
# Verify decryption works
|
149
|
+
xchacha_model = XChaChaIntegrationModel.new(model_id: 'xchacha-test')
|
150
|
+
xchacha_model.secret_data = 'xchacha20poly1305 integration test'
|
151
|
+
xchacha_model.secret_data
|
152
|
+
#=> "xchacha20poly1305 integration test"
|
153
|
+
|
154
|
+
|
155
|
+
# ALGORITHM PARAMETER FIX NEEDED:
|
156
|
+
#
|
157
|
+
# Problem: encrypted_field :secret_data, algorithm: 'aes-256-gcm'
|
158
|
+
# is ignored - always uses default XChaCha20Poly1305
|
159
|
+
#
|
160
|
+
# Root cause: EncryptedFieldType.encrypt_value always calls
|
161
|
+
# Familia::Encryption.encrypt() (default) instead of
|
162
|
+
# Familia::Encryption.encrypt_with(@algorithm, ...) when algorithm specified
|
163
|
+
#
|
164
|
+
# Fix required in lib/familia/features/encrypted_fields/encrypted_field_type.rb:
|
165
|
+
# 1. Add attr_reader :algorithm
|
166
|
+
# 2. Add algorithm: nil parameter to initialize()
|
167
|
+
# 3. Store @algorithm = algorithm
|
168
|
+
# 4. Update encrypt_value() to use encrypt_with(@algorithm, ...) when @algorithm present
|
169
|
+
#
|
170
|
+
# This enables per-field algorithm selection while maintaining backward compatibility
|
171
|
+
|
172
|
+
## TEST 8: Provider-specific integration: AES-GCM with forced algorithm
|
173
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
174
|
+
Familia.config.encryption_keys = test_keys
|
175
|
+
Familia.config.current_key_version = :v1
|
176
|
+
|
177
|
+
class AESIntegrationModel < Familia::Horreum
|
178
|
+
feature :encrypted_fields
|
179
|
+
identifier_field :model_id
|
180
|
+
|
181
|
+
field :model_id
|
182
|
+
encrypted_field :secret_data, algorithm: 'aes-256-gcm' # Specify the algorithm
|
183
|
+
end
|
184
|
+
|
185
|
+
aes_encrypted = Familia::Encryption.encrypt_with(
|
186
|
+
'aes-256-gcm',
|
187
|
+
'aes-gcm integration test',
|
188
|
+
context: 'AESIntegrationModel:secret_data:aes-test',
|
189
|
+
)
|
190
|
+
|
191
|
+
aes_model = AESIntegrationModel.new(model_id: 'aes-test')
|
192
|
+
|
193
|
+
# Manually encrypt with AES-GCM to test cross-algorithm compatibility
|
194
|
+
aes_model.instance_variable_set(:@secret_data, aes_encrypted)
|
195
|
+
|
196
|
+
# Verify AES-GCM algorithm is stored and decryption works
|
197
|
+
parsed_aes_data = JSON.parse(aes_encrypted, symbolize_names: true)
|
198
|
+
[parsed_aes_data[:algorithm], aes_model.secret_data]
|
199
|
+
##=> ["aes-256-gcm", "aes-gcm integration test"]
|
200
|
+
|
201
|
+
## TEST 9: Provider-specific integration: AES-GCM with forced algorithm
|
202
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
203
|
+
Familia.config.encryption_keys = test_keys
|
204
|
+
Familia.config.current_key_version = :v1
|
205
|
+
|
206
|
+
class AESIntegrationModel2 < Familia::Horreum
|
207
|
+
feature :encrypted_fields
|
208
|
+
identifier_field :model_id
|
209
|
+
field :model_id
|
210
|
+
encrypted_field :secret_data, algorithm: 'aes-256-gcm' # Specify the algorithm
|
211
|
+
end
|
212
|
+
|
213
|
+
aes_model = AESIntegrationModel2.new(model_id: 'aes-test')
|
214
|
+
aes_model.secret_data = 'aes-gcm integration test' # Use setter, not manual encryption
|
215
|
+
|
216
|
+
# Verify algorithm and decryption
|
217
|
+
encrypted_data = aes_model.instance_variable_get(:@secret_data)
|
218
|
+
parsed_data = JSON.parse(encrypted_data, symbolize_names: true)
|
219
|
+
[parsed_data[:algorithm], aes_model.secret_data]
|
220
|
+
##=> ["aes-256-gcm", "aes-gcm integration test"]
|