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.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +11 -8
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +19 -3
  7. data/README.md +36 -157
  8. data/docs/overview.md +359 -0
  9. data/docs/wiki/API-Reference.md +347 -0
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +600 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +106 -0
  17. data/docs/wiki/Implementation-Guide.md +276 -0
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  20. data/docs/wiki/Security-Model.md +183 -0
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/lib/familia/base.rb +18 -27
  23. data/lib/familia/connection.rb +6 -5
  24. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  25. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  26. data/lib/familia/data_type/types/counter.rb +38 -0
  27. data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
  28. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  31. data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
  32. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  33. data/lib/familia/{datatype.rb → data_type.rb} +12 -14
  34. data/lib/familia/encryption/encrypted_data.rb +137 -0
  35. data/lib/familia/encryption/manager.rb +119 -0
  36. data/lib/familia/encryption/provider.rb +49 -0
  37. data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
  38. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  39. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -0
  40. data/lib/familia/encryption/registry.rb +50 -0
  41. data/lib/familia/encryption.rb +178 -0
  42. data/lib/familia/encryption_request_cache.rb +68 -0
  43. data/lib/familia/errors.rb +17 -3
  44. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  45. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -0
  46. data/lib/familia/features/encrypted_fields.rb +28 -0
  47. data/lib/familia/features/expiration.rb +107 -77
  48. data/lib/familia/features/quantization.rb +5 -9
  49. data/lib/familia/features/relatable_objects.rb +2 -4
  50. data/lib/familia/features/safe_dump.rb +14 -17
  51. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  52. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  53. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  54. data/lib/familia/features/transient_fields.rb +47 -0
  55. data/lib/familia/features.rb +40 -24
  56. data/lib/familia/field_type.rb +273 -0
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
  58. data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
  63. data/lib/familia/horreum/subclass/definition.rb +469 -0
  64. data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +30 -22
  67. data/lib/familia/logging.rb +14 -14
  68. data/lib/familia/settings.rb +39 -3
  69. data/lib/familia/utils.rb +45 -0
  70. data/lib/familia/version.rb +1 -1
  71. data/lib/familia.rb +3 -2
  72. data/try/core/base_enhancements_try.rb +115 -0
  73. data/try/core/connection_try.rb +0 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -5
  77. data/try/core/familia_extended_try.rb +3 -4
  78. data/try/core/familia_try.rb +1 -2
  79. data/try/core/persistence_operations_try.rb +297 -0
  80. data/try/core/pools_try.rb +2 -2
  81. data/try/core/secure_identifier_try.rb +0 -1
  82. data/try/core/settings_try.rb +0 -1
  83. data/try/core/utils_try.rb +0 -1
  84. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  85. data/try/data_types/counter_try.rb +93 -0
  86. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  87. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  88. data/try/{datatypes → data_types}/list_try.rb +1 -2
  89. data/try/data_types/lock_try.rb +133 -0
  90. data/try/{datatypes → data_types}/set_try.rb +1 -2
  91. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  92. data/try/{datatypes → data_types}/string_try.rb +1 -2
  93. data/try/debugging/README.md +32 -0
  94. data/try/debugging/cache_behavior_tracer.rb +91 -0
  95. data/try/debugging/debug_aad_process.rb +82 -0
  96. data/try/debugging/debug_concealed_internal.rb +59 -0
  97. data/try/debugging/debug_concealed_reveal.rb +61 -0
  98. data/try/debugging/debug_context_aad.rb +68 -0
  99. data/try/debugging/debug_context_simple.rb +80 -0
  100. data/try/debugging/debug_cross_context.rb +62 -0
  101. data/try/debugging/debug_database_load.rb +64 -0
  102. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  103. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  104. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  105. data/try/debugging/debug_field_decrypt.rb +74 -0
  106. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  107. data/try/debugging/debug_load_path.rb +66 -0
  108. data/try/debugging/debug_method_definition.rb +46 -0
  109. data/try/debugging/debug_method_resolution.rb +41 -0
  110. data/try/debugging/debug_minimal.rb +24 -0
  111. data/try/debugging/debug_provider.rb +68 -0
  112. data/try/debugging/debug_secure_behavior.rb +73 -0
  113. data/try/debugging/debug_string_class.rb +46 -0
  114. data/try/debugging/debug_test.rb +46 -0
  115. data/try/debugging/debug_test_design.rb +80 -0
  116. data/try/debugging/encryption_method_tracer.rb +138 -0
  117. data/try/debugging/provider_diagnostics.rb +110 -0
  118. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  119. data/try/edge_cases/json_serialization_try.rb +0 -1
  120. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  121. data/try/encryption/config_persistence_try.rb +192 -0
  122. data/try/encryption/encryption_core_try.rb +328 -0
  123. data/try/encryption/instance_variable_scope_try.rb +31 -0
  124. data/try/encryption/module_loading_try.rb +28 -0
  125. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  126. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  127. data/try/encryption/roundtrip_validation_try.rb +28 -0
  128. data/try/encryption/secure_memory_handling_try.rb +125 -0
  129. data/try/features/encrypted_fields_core_try.rb +125 -0
  130. data/try/features/encrypted_fields_integration_try.rb +216 -0
  131. data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
  132. data/try/features/encrypted_fields_security_try.rb +377 -0
  133. data/try/features/encryption_fields/aad_protection_try.rb +138 -0
  134. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  135. data/try/features/encryption_fields/context_isolation_try.rb +141 -0
  136. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  137. data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
  138. data/try/features/encryption_fields/fresh_key_try.rb +168 -0
  139. data/try/features/encryption_fields/key_rotation_try.rb +123 -0
  140. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  141. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  142. data/try/features/encryption_fields/nonce_uniqueness_try.rb +56 -0
  143. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  144. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  145. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  146. data/try/features/expiration_try.rb +0 -1
  147. data/try/features/feature_dependencies_try.rb +159 -0
  148. data/try/features/quantization_try.rb +0 -1
  149. data/try/features/real_feature_integration_try.rb +148 -0
  150. data/try/features/relatable_objects_try.rb +0 -1
  151. data/try/features/safe_dump_advanced_try.rb +0 -1
  152. data/try/features/safe_dump_try.rb +0 -1
  153. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  154. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  155. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  156. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  157. data/try/features/transient_fields_core_try.rb +181 -0
  158. data/try/features/transient_fields_integration_try.rb +260 -0
  159. data/try/helpers/test_helpers.rb +67 -0
  160. data/try/horreum/base_try.rb +157 -3
  161. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  162. data/try/horreum/field_categories_try.rb +118 -0
  163. data/try/horreum/field_definition_try.rb +96 -0
  164. data/try/horreum/initialization_try.rb +1 -2
  165. data/try/horreum/relations_try.rb +1 -2
  166. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  167. data/try/horreum/serialization_try.rb +41 -7
  168. data/try/memory/memory_basic_test.rb +73 -0
  169. data/try/memory/memory_detailed_test.rb +121 -0
  170. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  171. data/try/memory/memory_search_for_string.rb +83 -0
  172. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  173. data/try/models/customer_safe_dump_try.rb +1 -2
  174. data/try/models/customer_try.rb +1 -2
  175. data/try/models/datatype_base_try.rb +1 -2
  176. data/try/models/familia_object_try.rb +0 -1
  177. metadata +131 -23
  178. 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