familia 2.0.0.pre4 → 2.0.0.pre6
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 +11 -8
- data/Gemfile +5 -1
- data/Gemfile.lock +19 -3
- data/README.md +36 -157
- data/docs/overview.md +359 -0
- data/docs/wiki/API-Reference.md +347 -0
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +600 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +106 -0
- data/docs/wiki/Implementation-Guide.md +276 -0
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +183 -0
- data/docs/wiki/Transient-Fields-Guide.md +280 -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/data_type/types/counter.rb +38 -0
- data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
- data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
- data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
- data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
- data/lib/familia/{datatype.rb → data_type.rb} +12 -14
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +119 -0
- data/lib/familia/encryption/provider.rb +49 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -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/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -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 +273 -0
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
- data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
- data/lib/familia/horreum/subclass/definition.rb +469 -0
- data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +30 -22
- 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 +3 -2
- data/try/core/base_enhancements_try.rb +115 -0
- data/try/core/connection_try.rb +0 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -5
- data/try/core/familia_extended_try.rb +3 -4
- data/try/core/familia_try.rb +1 -2
- data/try/core/persistence_operations_try.rb +297 -0
- 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/data_types/counter_try.rb +93 -0
- 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/data_types/lock_try.rb +133 -0
- 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/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/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 +125 -0
- data/try/features/encrypted_fields_integration_try.rb +216 -0
- data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
- data/try/features/encrypted_fields_security_try.rb +377 -0
- data/try/features/encryption_fields/aad_protection_try.rb +138 -0
- data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +141 -0
- data/try/features/encryption_fields/error_conditions_try.rb +116 -0
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
- data/try/features/encryption_fields/fresh_key_try.rb +168 -0
- data/try/features/encryption_fields/key_rotation_try.rb +123 -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 +56 -0
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +199 -0
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -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 +67 -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 +1 -2
- data/try/horreum/relations_try.rb +1 -2
- data/try/horreum/serialization_persistent_fields_try.rb +165 -0
- data/try/horreum/serialization_try.rb +41 -7
- 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 +1 -2
- data/try/models/customer_try.rb +1 -2
- data/try/models/datatype_base_try.rb +1 -2
- data/try/models/familia_object_try.rb +0 -1
- metadata +131 -23
- data/lib/familia/horreum/serialization.rb +0 -445
@@ -0,0 +1,138 @@
|
|
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
|
+
# Clean test environment
|
23
|
+
Familia.dbclient.flushdb
|
24
|
+
|
25
|
+
## AAD prevents field substitution attacks - proper cross-record test
|
26
|
+
@victim = AADProtectedModel.new(id: 'victim-1', email: 'victim@example.com')
|
27
|
+
@victim.save # Need to save first for AAD to be active
|
28
|
+
@victim.api_key = 'victim-secret-key'
|
29
|
+
@victim.save # Save the encrypted value
|
30
|
+
|
31
|
+
# Extract the raw encrypted JSON data (not the ConcealedString object)
|
32
|
+
@victim_encrypted_data = @victim.api_key.encrypted_value
|
33
|
+
|
34
|
+
# Create an attacker record with different AAD context (different email)
|
35
|
+
@attacker = AADProtectedModel.new(id: 'attacker-1', email: 'attacker@evil.com')
|
36
|
+
@attacker.save # Need to save for AAD to be active
|
37
|
+
|
38
|
+
# Simulate database tampering: set attacker's field to victim's encrypted data
|
39
|
+
# This simulates what an attacker with database access might try to do
|
40
|
+
@attacker.instance_variable_set(:@api_key,
|
41
|
+
ConcealedString.new(@victim_encrypted_data, @attacker, @attacker.class.field_types[:api_key]))
|
42
|
+
|
43
|
+
# Attempt to decrypt should fail due to AAD mismatch
|
44
|
+
@result1 = begin
|
45
|
+
decrypted_value = nil
|
46
|
+
@attacker.api_key.reveal { |plaintext| decrypted_value = plaintext }
|
47
|
+
"UNEXPECTED SUCCESS: #{decrypted_value}"
|
48
|
+
rescue Familia::EncryptionError => error
|
49
|
+
error.class.name
|
50
|
+
end
|
51
|
+
@result1
|
52
|
+
#=> "Familia::EncryptionError"
|
53
|
+
|
54
|
+
## Verify error message indicates decryption failure
|
55
|
+
@result2 = begin
|
56
|
+
@attacker.api_key.reveal { |plaintext| plaintext }
|
57
|
+
"No error occurred"
|
58
|
+
rescue Familia::EncryptionError => error
|
59
|
+
error.message.include?('Decryption failed')
|
60
|
+
end
|
61
|
+
@result2
|
62
|
+
#=> true
|
63
|
+
|
64
|
+
## Cross-record attack with same email (should still fail due to different identifiers)
|
65
|
+
victim2 = AADProtectedModel.new(id: 'victim-2', email: 'shared@example.com')
|
66
|
+
victim2.save
|
67
|
+
victim2.api_key = 'victim2-secret'
|
68
|
+
victim2.save
|
69
|
+
|
70
|
+
attacker2 = AADProtectedModel.new(id: 'attacker-2', email: 'shared@example.com') # Same email!
|
71
|
+
attacker2.save
|
72
|
+
|
73
|
+
# Extract victim's encrypted data and try to decrypt with attacker's context
|
74
|
+
victim2_encrypted_data = victim2.api_key.encrypted_value
|
75
|
+
attacker2.instance_variable_set(:@api_key,
|
76
|
+
ConcealedString.new(victim2_encrypted_data, attacker2, attacker2.class.field_types[:api_key]))
|
77
|
+
|
78
|
+
# Should fail because identifier is part of AAD even when aad_fields match
|
79
|
+
@result3 = begin
|
80
|
+
attacker2.api_key.reveal { |plaintext| plaintext }
|
81
|
+
"UNEXPECTED SUCCESS"
|
82
|
+
rescue Familia::EncryptionError => error
|
83
|
+
error.class.name
|
84
|
+
end
|
85
|
+
@result3
|
86
|
+
#=> "Familia::EncryptionError"
|
87
|
+
|
88
|
+
## Without saving, AAD is not enforced (no database context)
|
89
|
+
unsaved_model = AADProtectedModel.new(id: 'unsaved-1', email: 'test@example.com')
|
90
|
+
unsaved_model.api_key = 'test-key'
|
91
|
+
|
92
|
+
# Change email after encryption but before save - should still work
|
93
|
+
unsaved_model.email = 'changed@example.com'
|
94
|
+
decrypted = nil
|
95
|
+
unsaved_model.api_key.reveal { |plaintext| decrypted = plaintext }
|
96
|
+
decrypted
|
97
|
+
#=> "test-key"
|
98
|
+
|
99
|
+
## Cross-model attack with raw encrypted JSON
|
100
|
+
# Demonstrate that raw encrypted data can't be moved between models
|
101
|
+
@json_victim = AADProtectedModel.new(id: 'json-victim-1', email: 'jsonvictim@example.com')
|
102
|
+
@json_victim.save
|
103
|
+
@json_victim.api_key = 'json-victim-secret'
|
104
|
+
|
105
|
+
# Get the raw encrypted JSON and create a new ConcealedString for different record
|
106
|
+
@raw_encrypted_json = @json_victim.api_key.encrypted_value
|
107
|
+
@json_attacker = AADProtectedModel.new(id: 'json-attacker-1', email: 'jsonattacker@evil.com')
|
108
|
+
@json_attacker.save
|
109
|
+
|
110
|
+
# Create ConcealedString with stolen encrypted JSON for the attacker
|
111
|
+
@fake_concealed = ConcealedString.new(@raw_encrypted_json, @json_attacker, @json_attacker.class.field_types[:api_key])
|
112
|
+
|
113
|
+
# Attempt decryption should fail
|
114
|
+
@result4 = begin
|
115
|
+
@fake_concealed.reveal { |plaintext| plaintext }
|
116
|
+
"UNEXPECTED SUCCESS"
|
117
|
+
rescue Familia::EncryptionError => error
|
118
|
+
error.class.name
|
119
|
+
end
|
120
|
+
@result4
|
121
|
+
#=> "Familia::EncryptionError"
|
122
|
+
|
123
|
+
## Successful decryption with correct context (control test)
|
124
|
+
legitimate_user = AADProtectedModel.new(id: 'legitimate-1', email: 'legit@example.com')
|
125
|
+
legitimate_user.save
|
126
|
+
legitimate_user.api_key = 'legitimate-secret'
|
127
|
+
legitimate_user.save
|
128
|
+
|
129
|
+
# Normal decryption should work
|
130
|
+
decrypted_legit = nil
|
131
|
+
legitimate_user.api_key.reveal { |plaintext| decrypted_legit = plaintext }
|
132
|
+
decrypted_legit
|
133
|
+
#=> "legitimate-secret"
|
134
|
+
|
135
|
+
# Cleanup
|
136
|
+
Familia.dbclient.flushdb
|
137
|
+
Familia.config.encryption_keys = nil
|
138
|
+
Familia.config.current_key_version = nil
|
@@ -0,0 +1,250 @@
|
|
1
|
+
# try/features/encryption_fields/concealed_string_core_try.rb
|
2
|
+
|
3
|
+
require_relative '../../helpers/test_helpers'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
Familia.debug = false
|
7
|
+
|
8
|
+
# Configure encryption keys
|
9
|
+
test_keys = { v1: Base64.strict_encode64('a' * 32) }
|
10
|
+
Familia.config.encryption_keys = test_keys
|
11
|
+
Familia.config.current_key_version = :v1
|
12
|
+
|
13
|
+
# Test class with encrypted fields
|
14
|
+
class TestSecretDocument < Familia::Horreum
|
15
|
+
feature :encrypted_fields
|
16
|
+
identifier_field :id
|
17
|
+
field :id
|
18
|
+
field :title # Regular field for comparison
|
19
|
+
encrypted_field :content # This will use ConcealedString
|
20
|
+
encrypted_field :api_key # Another encrypted field
|
21
|
+
end
|
22
|
+
|
23
|
+
# Assign it to the global namespace for proper naming
|
24
|
+
Object.const_set(:SecretDocument, TestSecretDocument)
|
25
|
+
|
26
|
+
# Clean test environment
|
27
|
+
Familia.dbclient.flushdb
|
28
|
+
|
29
|
+
# Create test document
|
30
|
+
@doc = SecretDocument.new
|
31
|
+
@doc.id = "test123"
|
32
|
+
@doc.title = "Public Title"
|
33
|
+
@doc.content = "secret information"
|
34
|
+
@doc.api_key = "sk-1234567890"
|
35
|
+
|
36
|
+
## Basic ConcealedString creation
|
37
|
+
@doc.content.class.name
|
38
|
+
#=> "ConcealedString"
|
39
|
+
|
40
|
+
## API key also returns ConcealedString
|
41
|
+
@doc.api_key.class.name
|
42
|
+
#=> "ConcealedString"
|
43
|
+
|
44
|
+
## Reveal API - controlled decryption
|
45
|
+
revealed_content = nil
|
46
|
+
@doc.content.reveal do |plaintext|
|
47
|
+
revealed_content = plaintext
|
48
|
+
end
|
49
|
+
revealed_content
|
50
|
+
#=> "secret information"
|
51
|
+
|
52
|
+
## Reveal can be called multiple times
|
53
|
+
revealed_again = nil
|
54
|
+
@doc.content.reveal do |plaintext|
|
55
|
+
revealed_again = plaintext
|
56
|
+
end
|
57
|
+
revealed_again
|
58
|
+
#=> "secret information"
|
59
|
+
|
60
|
+
## Reveal requires block argument
|
61
|
+
begin
|
62
|
+
@doc.content.reveal # No block provided
|
63
|
+
rescue ArgumentError => e
|
64
|
+
e.message
|
65
|
+
end
|
66
|
+
#=> "Block required for reveal"
|
67
|
+
|
68
|
+
## Universal Serialization Safety - to_s
|
69
|
+
@doc.content.to_s
|
70
|
+
#=> "[CONCEALED]"
|
71
|
+
|
72
|
+
## inspect method
|
73
|
+
@doc.content.inspect
|
74
|
+
#=> "[CONCEALED]"
|
75
|
+
|
76
|
+
## to_str method should not exist for security (implicit string conversion)
|
77
|
+
@doc.content.to_str
|
78
|
+
#=!> NoMethodError
|
79
|
+
|
80
|
+
## JSON serialization - to_json
|
81
|
+
@doc.content.to_json
|
82
|
+
#=> "\"[CONCEALED]\""
|
83
|
+
|
84
|
+
## JSON serialization - as_json
|
85
|
+
@doc.content.as_json
|
86
|
+
#=> "[CONCEALED]"
|
87
|
+
|
88
|
+
## Hash conversion
|
89
|
+
@doc.content.to_h
|
90
|
+
#=> "[CONCEALED]"
|
91
|
+
|
92
|
+
## Array conversion
|
93
|
+
@doc.content.to_a
|
94
|
+
#=> ["[CONCEALED]"]
|
95
|
+
|
96
|
+
## String concatenation safety
|
97
|
+
(@doc.content + " extra")
|
98
|
+
#=> "[CONCEALED]"
|
99
|
+
|
100
|
+
## Length operation
|
101
|
+
@doc.content.length
|
102
|
+
#=> 11
|
103
|
+
|
104
|
+
## Empty check
|
105
|
+
@doc.content.empty?
|
106
|
+
#=> false
|
107
|
+
|
108
|
+
## Present check
|
109
|
+
@doc.content.present?
|
110
|
+
#=> true
|
111
|
+
|
112
|
+
## Equality operations - different objects not equal
|
113
|
+
@content1 = @doc.content
|
114
|
+
@content2 = @doc.api_key
|
115
|
+
(@content1 == @content2)
|
116
|
+
#=> false
|
117
|
+
|
118
|
+
## Same object equality
|
119
|
+
(@content1 == @content1)
|
120
|
+
#=> true
|
121
|
+
|
122
|
+
## Hash consistency for timing attack prevention
|
123
|
+
(@content1.hash == @content2.hash)
|
124
|
+
#=> true
|
125
|
+
|
126
|
+
## Pattern matching - deconstruct
|
127
|
+
@doc.content.deconstruct
|
128
|
+
#=> ["[CONCEALED]"]
|
129
|
+
|
130
|
+
## Pattern matching - deconstruct_keys
|
131
|
+
@doc.content.deconstruct_keys([])
|
132
|
+
#=:> Hash
|
133
|
+
|
134
|
+
## Enumeration safety
|
135
|
+
@doc.content.map { |x| x.upcase }
|
136
|
+
#=> ["[CONCEALED]"]
|
137
|
+
|
138
|
+
## Encrypted data access for storage
|
139
|
+
@encrypted_data = @doc.content.encrypted_value
|
140
|
+
@encrypted_data
|
141
|
+
#=:> String
|
142
|
+
|
143
|
+
## Encrypted data is valid JSON
|
144
|
+
begin
|
145
|
+
parsed = JSON.parse(@encrypted_data)
|
146
|
+
parsed.key?('algorithm')
|
147
|
+
rescue
|
148
|
+
false
|
149
|
+
end
|
150
|
+
#=> true
|
151
|
+
|
152
|
+
## Memory clearing functionality
|
153
|
+
# Create a separate document for clearing tests to avoid affecting other tests
|
154
|
+
@clear_doc = SecretDocument.new
|
155
|
+
@clear_doc.id = "clear_test"
|
156
|
+
@clear_doc.content = "data to be cleared"
|
157
|
+
@test_concealed = @clear_doc.content
|
158
|
+
@test_concealed.cleared?
|
159
|
+
#=> false
|
160
|
+
|
161
|
+
## Clear operation
|
162
|
+
@test_concealed.clear!
|
163
|
+
@test_concealed.cleared?
|
164
|
+
#=> true
|
165
|
+
|
166
|
+
## After clearing, reveal raises error
|
167
|
+
begin
|
168
|
+
@test_concealed.reveal { |x| x }
|
169
|
+
rescue SecurityError => e
|
170
|
+
e.message
|
171
|
+
end
|
172
|
+
#=> "Encrypted data already cleared"
|
173
|
+
|
174
|
+
## String interpolation safety
|
175
|
+
interpolated = "Content: #{@doc.content}"
|
176
|
+
interpolated
|
177
|
+
#=> "Content: [CONCEALED]"
|
178
|
+
|
179
|
+
## Array inclusion safety
|
180
|
+
debug_array = [@doc.title, @doc.content, @doc.api_key]
|
181
|
+
debug_array.map(&:to_s)
|
182
|
+
#=> ["Public Title", "[CONCEALED]", "[CONCEALED]"]
|
183
|
+
|
184
|
+
## Database persistence - debug serialization
|
185
|
+
@storage_hash = @doc.to_h_for_storage
|
186
|
+
@storage_hash.keys
|
187
|
+
#=> ["id", "title", "content", "api_key"]
|
188
|
+
|
189
|
+
@save_result1 = @doc.save
|
190
|
+
@save_result1
|
191
|
+
#=> true
|
192
|
+
|
193
|
+
## After saving, re-encrypt with proper AAD context
|
194
|
+
@doc.content = "secret information" # Re-encrypt now that record exists
|
195
|
+
@save_result2 = @doc.save
|
196
|
+
@save_result2
|
197
|
+
#=> true
|
198
|
+
|
199
|
+
## After saving, behavior is identical
|
200
|
+
@doc.content.to_s
|
201
|
+
#=> "[CONCEALED]"
|
202
|
+
|
203
|
+
## Post-save reveal works
|
204
|
+
@doc.content.reveal { |x| x }
|
205
|
+
#=> "secret information"
|
206
|
+
|
207
|
+
## Fresh load from database
|
208
|
+
@fresh_doc = SecretDocument.load("test123")
|
209
|
+
@fresh_doc&.content&.class&.name || "nil or missing"
|
210
|
+
#=> "ConcealedString"
|
211
|
+
|
212
|
+
## Debug what's actually in the database
|
213
|
+
@all_keys = Familia.dbclient.keys("*")
|
214
|
+
@all_keys
|
215
|
+
#=> ["secretdocument:test123:object"]
|
216
|
+
|
217
|
+
@db_hash = Familia.dbclient.hgetall("secretdocument:test123:object")
|
218
|
+
@db_hash.keys
|
219
|
+
#=> ["id", "title", "content", "api_key"]
|
220
|
+
|
221
|
+
db_content = Familia.dbclient.hget("secretdocument:test123:object", "content")
|
222
|
+
db_content&.class&.name || "nil"
|
223
|
+
#=> "String"
|
224
|
+
|
225
|
+
## Fresh load reveal works (if content exists)
|
226
|
+
if @fresh_doc&.content.respond_to?(:reveal)
|
227
|
+
begin
|
228
|
+
@fresh_doc.content.reveal { |x| x }
|
229
|
+
rescue => e
|
230
|
+
"DECRYPTION ERROR: #{e.class}: #{e.message}"
|
231
|
+
end
|
232
|
+
else
|
233
|
+
"content is nil or missing"
|
234
|
+
end
|
235
|
+
#=> "secret information"
|
236
|
+
|
237
|
+
## Regular fields unaffected
|
238
|
+
@doc.title
|
239
|
+
#=:> String
|
240
|
+
|
241
|
+
## Regular field access
|
242
|
+
@doc.title
|
243
|
+
#=> "Public Title"
|
244
|
+
|
245
|
+
## Mixed field operations
|
246
|
+
(@doc.title + " has concealed content")
|
247
|
+
#=> "Public Title has concealed content"
|
248
|
+
|
249
|
+
# Teardown
|
250
|
+
Familia.dbclient.flushdb
|
@@ -0,0 +1,141 @@
|
|
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 - access via refinement
|
51
|
+
@user1_decrypted = nil
|
52
|
+
module User1TestAccess
|
53
|
+
using ConcealedStringTestHelper
|
54
|
+
user1 = IsolationUser.new(user_id: 'alice')
|
55
|
+
user1.secret = 'shared-secret'
|
56
|
+
user1.secret.reveal_for_testing
|
57
|
+
end
|
58
|
+
#=> 'shared-secret'
|
59
|
+
|
60
|
+
@user2_decrypted = nil
|
61
|
+
module User2TestAccess
|
62
|
+
using ConcealedStringTestHelper
|
63
|
+
user2 = IsolationUser.new(user_id: 'bob')
|
64
|
+
user2.secret = 'shared-secret'
|
65
|
+
user2.secret.reveal_for_testing
|
66
|
+
end
|
67
|
+
#=> 'shared-secret'
|
68
|
+
|
69
|
+
## Different model classes have isolated encryption contexts
|
70
|
+
@model_a = ModelA.new(id: 'same-id')
|
71
|
+
@model_b = ModelB.new(id: 'same-id')
|
72
|
+
|
73
|
+
@model_a.api_key = 'secret-key'
|
74
|
+
@model_b.api_key = 'secret-key'
|
75
|
+
|
76
|
+
@cipher_a = @model_a.instance_variable_get(:@api_key)
|
77
|
+
@cipher_b = @model_b.instance_variable_get(:@api_key)
|
78
|
+
|
79
|
+
@cipher_a != @cipher_b
|
80
|
+
#=> true
|
81
|
+
|
82
|
+
## Model A can decrypt its own data - access via refinement
|
83
|
+
module ModelATestAccess
|
84
|
+
using ConcealedStringTestHelper
|
85
|
+
model_a = ModelA.new(id: 'same-id')
|
86
|
+
model_a.api_key = 'secret-key'
|
87
|
+
model_a.api_key.reveal_for_testing
|
88
|
+
end
|
89
|
+
#=> 'secret-key'
|
90
|
+
|
91
|
+
## Model B can decrypt its own data - access via refinement
|
92
|
+
module ModelBTestAccess
|
93
|
+
using ConcealedStringTestHelper
|
94
|
+
model_b = ModelB.new(id: 'same-id')
|
95
|
+
model_b.api_key = 'secret-key'
|
96
|
+
model_b.api_key.reveal_for_testing
|
97
|
+
end
|
98
|
+
#=> 'secret-key'
|
99
|
+
|
100
|
+
## Cross-model decryption fails due to context mismatch
|
101
|
+
@model_a.instance_variable_set(:@api_key, @cipher_b)
|
102
|
+
begin
|
103
|
+
@model_a.api_key
|
104
|
+
false
|
105
|
+
rescue Familia::EncryptionError
|
106
|
+
true
|
107
|
+
end
|
108
|
+
#=> true
|
109
|
+
|
110
|
+
## Different field names in same model create different contexts
|
111
|
+
class MultiFieldModel < Familia::Horreum
|
112
|
+
feature :encrypted_fields
|
113
|
+
identifier_field :id
|
114
|
+
field :id
|
115
|
+
encrypted_field :field_one
|
116
|
+
encrypted_field :field_two
|
117
|
+
end
|
118
|
+
|
119
|
+
@multi = MultiFieldModel.new(id: 'test')
|
120
|
+
@multi.field_one = 'same-value'
|
121
|
+
@multi.field_two = 'same-value'
|
122
|
+
|
123
|
+
@cipher_field1 = @multi.instance_variable_get(:@field_one)
|
124
|
+
@cipher_field2 = @multi.instance_variable_get(:@field_two)
|
125
|
+
|
126
|
+
@cipher_field1 != @cipher_field2
|
127
|
+
#=> true
|
128
|
+
|
129
|
+
## Cross-field decryption fails due to field context isolation
|
130
|
+
@multi.instance_variable_set(:@field_one, @cipher_field2)
|
131
|
+
begin
|
132
|
+
@multi.field_one
|
133
|
+
false
|
134
|
+
rescue Familia::EncryptionError
|
135
|
+
true
|
136
|
+
end
|
137
|
+
#=> true
|
138
|
+
|
139
|
+
# Cleanup
|
140
|
+
Familia.config.encryption_keys = nil
|
141
|
+
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?('Invalid JSON structure')
|
28
|
+
|
29
|
+
## Tampered auth tag fails decryption
|
30
|
+
@model.secret = 'valid-secret'
|
31
|
+
@valid_cipher = @model.secret.encrypted_value
|
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 auth_tag size')
|
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?('Invalid Base64 encoding')
|
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.secret.encrypted_value)
|
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.secret.encrypted_value)
|
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
|