familia 2.0.0.pre3 → 2.0.0.pre5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +3 -3
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +18 -3
  7. data/README.md +36 -157
  8. data/TEST_COVERAGE.md +40 -0
  9. data/docs/overview.md +359 -0
  10. data/docs/wiki/API-Reference.md +270 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
  12. data/docs/wiki/Home.md +49 -0
  13. data/docs/wiki/Implementation-Guide.md +183 -0
  14. data/docs/wiki/Security-Model.md +143 -0
  15. data/lib/familia/base.rb +18 -27
  16. data/lib/familia/connection.rb +6 -5
  17. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  18. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  19. data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
  20. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  21. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  22. data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
  23. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  24. data/lib/familia/{datatype.rb → data_type.rb} +10 -12
  25. data/lib/familia/encryption/manager.rb +102 -0
  26. data/lib/familia/encryption/provider.rb +49 -0
  27. data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
  28. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  29. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
  30. data/lib/familia/encryption/registry.rb +50 -0
  31. data/lib/familia/encryption.rb +178 -0
  32. data/lib/familia/encryption_request_cache.rb +68 -0
  33. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
  34. data/lib/familia/features/encrypted_fields.rb +28 -0
  35. data/lib/familia/features/expiration.rb +107 -77
  36. data/lib/familia/features/quantization.rb +5 -9
  37. data/lib/familia/features/relatable_objects.rb +2 -4
  38. data/lib/familia/features/safe_dump.rb +14 -17
  39. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  40. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  41. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  42. data/lib/familia/features/transient_fields.rb +47 -0
  43. data/lib/familia/features.rb +40 -24
  44. data/lib/familia/field_type.rb +270 -0
  45. data/lib/familia/horreum/connection.rb +8 -11
  46. data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
  47. data/lib/familia/horreum/definition_methods.rb +453 -0
  48. data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -229
  49. data/lib/familia/horreum/serialization.rb +46 -18
  50. data/lib/familia/horreum/settings.rb +10 -2
  51. data/lib/familia/horreum/utils.rb +9 -10
  52. data/lib/familia/horreum.rb +18 -10
  53. data/lib/familia/logging.rb +14 -14
  54. data/lib/familia/settings.rb +39 -3
  55. data/lib/familia/utils.rb +45 -0
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -1
  58. data/try/core/base_enhancements_try.rb +115 -0
  59. data/try/core/connection_try.rb +0 -1
  60. data/try/core/errors_try.rb +0 -1
  61. data/try/core/familia_extended_try.rb +3 -4
  62. data/try/core/familia_try.rb +0 -1
  63. data/try/core/pools_try.rb +2 -2
  64. data/try/core/secure_identifier_try.rb +0 -1
  65. data/try/core/settings_try.rb +0 -1
  66. data/try/core/utils_try.rb +0 -1
  67. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  68. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  69. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  70. data/try/{datatypes → data_types}/list_try.rb +1 -2
  71. data/try/{datatypes → data_types}/set_try.rb +1 -2
  72. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  73. data/try/{datatypes → data_types}/string_try.rb +1 -2
  74. data/try/debugging/README.md +32 -0
  75. data/try/debugging/cache_behavior_tracer.rb +91 -0
  76. data/try/debugging/encryption_method_tracer.rb +138 -0
  77. data/try/debugging/provider_diagnostics.rb +110 -0
  78. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  79. data/try/edge_cases/json_serialization_try.rb +0 -1
  80. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  81. data/try/encryption/config_persistence_try.rb +192 -0
  82. data/try/encryption/encryption_core_try.rb +328 -0
  83. data/try/encryption/instance_variable_scope_try.rb +31 -0
  84. data/try/encryption/module_loading_try.rb +28 -0
  85. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  86. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  87. data/try/encryption/roundtrip_validation_try.rb +28 -0
  88. data/try/encryption/secure_memory_handling_try.rb +125 -0
  89. data/try/features/encrypted_fields_core_try.rb +117 -0
  90. data/try/features/encrypted_fields_integration_try.rb +220 -0
  91. data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
  92. data/try/features/encrypted_fields_security_try.rb +370 -0
  93. data/try/features/encryption_fields/aad_protection_try.rb +53 -0
  94. data/try/features/encryption_fields/context_isolation_try.rb +120 -0
  95. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  96. data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
  97. data/try/features/encryption_fields/fresh_key_try.rb +163 -0
  98. data/try/features/encryption_fields/key_rotation_try.rb +117 -0
  99. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  100. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  101. data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
  102. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  103. data/try/features/expiration_try.rb +0 -1
  104. data/try/features/feature_dependencies_try.rb +159 -0
  105. data/try/features/quantization_try.rb +0 -1
  106. data/try/features/real_feature_integration_try.rb +148 -0
  107. data/try/features/relatable_objects_try.rb +0 -1
  108. data/try/features/safe_dump_advanced_try.rb +0 -1
  109. data/try/features/safe_dump_try.rb +0 -1
  110. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  111. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  112. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  113. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  114. data/try/features/transient_fields_core_try.rb +181 -0
  115. data/try/features/transient_fields_integration_try.rb +260 -0
  116. data/try/helpers/test_helpers.rb +42 -0
  117. data/try/horreum/base_try.rb +157 -3
  118. data/try/horreum/class_methods_try.rb +27 -36
  119. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  120. data/try/horreum/field_categories_try.rb +118 -0
  121. data/try/horreum/field_definition_try.rb +96 -0
  122. data/try/horreum/initialization_try.rb +0 -1
  123. data/try/horreum/relations_try.rb +0 -1
  124. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  125. data/try/horreum/serialization_try.rb +2 -3
  126. data/try/memory/memory_basic_test.rb +73 -0
  127. data/try/memory/memory_detailed_test.rb +121 -0
  128. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  129. data/try/memory/memory_search_for_string.rb +83 -0
  130. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  131. data/try/models/customer_safe_dump_try.rb +0 -1
  132. data/try/models/customer_try.rb +0 -1
  133. data/try/models/datatype_base_try.rb +1 -2
  134. data/try/models/familia_object_try.rb +0 -1
  135. metadata +85 -18
@@ -0,0 +1,120 @@
1
+ # try/features/encryption_fields/context_isolation_try.rb
2
+
3
+ require 'base64'
4
+
5
+ require_relative '../../helpers/test_helpers'
6
+
7
+ # Setup encryption keys for testing
8
+ test_keys = {
9
+ v1: Base64.strict_encode64('a' * 32),
10
+ v2: Base64.strict_encode64('b' * 32)
11
+ }
12
+ Familia.config.encryption_keys = test_keys
13
+ Familia.config.current_key_version = :v1
14
+
15
+ class IsolationUser < Familia::Horreum
16
+ feature :encrypted_fields
17
+ identifier_field :user_id
18
+ field :user_id
19
+ encrypted_field :secret
20
+ end
21
+
22
+ # Different models with same field name have isolated contexts
23
+ class ModelA < Familia::Horreum
24
+ feature :encrypted_fields
25
+ identifier_field :id
26
+ field :id
27
+ encrypted_field :api_key
28
+ end
29
+
30
+ class ModelB < Familia::Horreum
31
+ feature :encrypted_fields
32
+ identifier_field :id
33
+ field :id
34
+ encrypted_field :api_key
35
+ end
36
+
37
+ ## Different user IDs produce different ciphertexts for same plaintext
38
+ @user1 = IsolationUser.new(user_id: 'alice')
39
+ @user2 = IsolationUser.new(user_id: 'bob')
40
+
41
+ @user1.secret = 'shared-secret'
42
+ @user2.secret = 'shared-secret'
43
+
44
+ @cipher1 = @user1.instance_variable_get(:@secret)
45
+ @cipher2 = @user2.instance_variable_get(:@secret)
46
+
47
+ @cipher1 != @cipher2
48
+ #=> true
49
+
50
+ ## Same plaintext decrypts correctly for both users
51
+ @user1.secret
52
+ #=> 'shared-secret'
53
+
54
+ ## Same plaintext decrypts correctly for both users
55
+ @user2.secret
56
+ #=> 'shared-secret'
57
+
58
+ ## Different model classes have isolated encryption contexts
59
+ @model_a = ModelA.new(id: 'same-id')
60
+ @model_b = ModelB.new(id: 'same-id')
61
+
62
+ @model_a.api_key = 'secret-key'
63
+ @model_b.api_key = 'secret-key'
64
+
65
+ @cipher_a = @model_a.instance_variable_get(:@api_key)
66
+ @cipher_b = @model_b.instance_variable_get(:@api_key)
67
+
68
+ @cipher_a != @cipher_b
69
+ #=> true
70
+
71
+ ## Model A can decrypt its own data
72
+ @model_a.api_key
73
+ #=> 'secret-key'
74
+
75
+ ## Model B can decrypt its own data
76
+ @model_b.api_key
77
+ #=> 'secret-key'
78
+
79
+ ## Cross-model decryption fails due to context mismatch
80
+ @model_a.instance_variable_set(:@api_key, @cipher_b)
81
+ begin
82
+ @model_a.api_key
83
+ false
84
+ rescue Familia::EncryptionError
85
+ true
86
+ end
87
+ #=> true
88
+
89
+ ## Different field names in same model create different contexts
90
+ class MultiFieldModel < Familia::Horreum
91
+ feature :encrypted_fields
92
+ identifier_field :id
93
+ field :id
94
+ encrypted_field :field_one
95
+ encrypted_field :field_two
96
+ end
97
+
98
+ @multi = MultiFieldModel.new(id: 'test')
99
+ @multi.field_one = 'same-value'
100
+ @multi.field_two = 'same-value'
101
+
102
+ @cipher_field1 = @multi.instance_variable_get(:@field_one)
103
+ @cipher_field2 = @multi.instance_variable_get(:@field_two)
104
+
105
+ @cipher_field1 != @cipher_field2
106
+ #=> true
107
+
108
+ ## Cross-field decryption fails due to field context isolation
109
+ @multi.instance_variable_set(:@field_one, @cipher_field2)
110
+ begin
111
+ @multi.field_one
112
+ false
113
+ rescue Familia::EncryptionError
114
+ true
115
+ end
116
+ #=> true
117
+
118
+ # Cleanup
119
+ Familia.config.encryption_keys = nil
120
+ Familia.config.current_key_version = nil
@@ -0,0 +1,116 @@
1
+ # try/features/encryption_fields/error_conditions_try.rb
2
+
3
+ require 'base64'
4
+
5
+ require_relative '../../helpers/test_helpers'
6
+
7
+ # Setup encryption keys for error testing
8
+ @test_keys = {
9
+ v1: Base64.strict_encode64('a' * 32),
10
+ v2: Base64.strict_encode64('b' * 32)
11
+ }
12
+ Familia.config.encryption_keys = @test_keys
13
+ Familia.config.current_key_version = :v1
14
+
15
+ class ErrorTest < Familia::Horreum
16
+ feature :encrypted_fields
17
+ identifier_field :id
18
+ field :id
19
+ encrypted_field :secret
20
+ end
21
+
22
+ ## Malformed JSON raises appropriate error
23
+ @model = ErrorTest.new(id: 'err-1')
24
+ @model.instance_variable_set(:@secret, 'not-json{]')
25
+ @model.secret
26
+ #=!> Familia::EncryptionError
27
+ #==> error.message.include?('Decryption failed')
28
+
29
+ ## Tampered auth tag fails decryption
30
+ @model.secret = 'valid-secret'
31
+ @valid_cipher = @model.instance_variable_get(:@secret)
32
+ @tampered = JSON.parse(@valid_cipher)
33
+ @tampered['auth_tag'] = Base64.strict_encode64('tampered' * 4)
34
+ @model.instance_variable_set(:@secret, @tampered.to_json)
35
+
36
+ @model.secret
37
+ #=!> Familia::EncryptionError
38
+ #==> error.message.include?('Invalid encrypted data')
39
+
40
+ ## Missing encryption config raises on validation
41
+ @original_keys = Familia.config.encryption_keys
42
+ Familia.config.encryption_keys = nil
43
+ Familia::Encryption.validate_configuration!
44
+ Familia.config.encryption_keys = @original_keys
45
+ #=!> Familia::EncryptionError
46
+ #==> error.message.include?('No encryption keys configured')
47
+
48
+ ## Invalid Base64 in stored data causes decryption failure
49
+ # Reset keys for this test since they were cleared in previous test
50
+ Familia.config.encryption_keys = @test_keys
51
+ @model.instance_variable_set(:@secret, '{"algorithm":"aes-256-gcm","nonce":"!!!invalid!!!","ciphertext":"test","auth_tag":"test","key_version":"v1"}')
52
+ @model.secret
53
+ #=!> Familia::EncryptionError
54
+ #==> error.message.include?('Decryption failed')
55
+
56
+ ## Derivation counter still increments on decryption errors
57
+ Familia::Encryption.reset_derivation_count!
58
+ # Ensure keys are available for this test
59
+ Familia.config.encryption_keys = @test_keys
60
+ @error_model = ErrorTest.new(id: 'err-counter')
61
+ # Set valid JSON but with invalid base64 data to trigger decrypt failure after parsing
62
+ @error_model.instance_variable_set(:@secret, '{"algorithm":"aes-256-gcm","nonce":"dGVzdA==","ciphertext":"invalid-base64!!!","auth_tag":"dGVzdA==","key_version":"v1"}')
63
+ begin
64
+ @error_model.secret
65
+ rescue
66
+ end
67
+ # Derivation attempted even if decrypt fails
68
+ Familia::Encryption.derivation_count.value
69
+ #=> 1
70
+
71
+ ## Unsupported algorithm in encrypted data
72
+ # Ensure keys are available for this test
73
+ Familia.config.encryption_keys = @test_keys
74
+ @model.secret = 'test-data'
75
+ @cipher_data = JSON.parse(@model.instance_variable_get(:@secret))
76
+ @cipher_data['algorithm'] = 'unsupported-algorithm'
77
+ @model.instance_variable_set(:@secret, @cipher_data.to_json)
78
+
79
+ @model.secret
80
+ #=!> Familia::EncryptionError
81
+ #==> error.message.include?('Unsupported algorithm')
82
+
83
+ ## Missing current key version causes validation error
84
+ @original_version = Familia.config.current_key_version
85
+ Familia.config.current_key_version = nil
86
+ Familia::Encryption.validate_configuration!
87
+ Familia.config.current_key_version = @original_version
88
+ #=!> Familia::EncryptionError
89
+ #==> error.message.include?('No current key version set')
90
+
91
+ ## Invalid key version in encrypted data
92
+ # Ensure keys are available for this test
93
+ Familia.config.encryption_keys = @test_keys
94
+ Familia.config.current_key_version = :v1
95
+ @model.secret = 'test-data'
96
+ @cipher_with_bad_version = JSON.parse(@model.instance_variable_get(:@secret))
97
+ @cipher_with_bad_version['key_version'] = 'nonexistent'
98
+ @model.instance_variable_set(:@secret, @cipher_with_bad_version.to_json)
99
+ @model.secret
100
+ #=!> Familia::EncryptionError
101
+ #==> error.message.include?('No key for version: nonexistent')
102
+
103
+ ## Empty string and nil values don't trigger encryption errors
104
+ @empty_model = ErrorTest.new(id: 'empty-test')
105
+ @empty_model.secret = ''
106
+ @empty_model.secret
107
+ #=> nil
108
+
109
+ ## Nil assignment and retrieval
110
+ @empty_model.secret = nil
111
+ @empty_model.secret
112
+ #=> nil
113
+
114
+ # Cleanup
115
+ Familia.config.encryption_keys = nil
116
+ Familia.config.current_key_version = nil
@@ -0,0 +1,122 @@
1
+ # try/features/encryption_fields/fresh_key_derivation_try.rb
2
+
3
+ require 'base64'
4
+
5
+ require_relative '../../helpers/test_helpers'
6
+
7
+ test_keys = {
8
+ v1: Base64.strict_encode64('a' * 32),
9
+ v2: Base64.strict_encode64('b' * 32)
10
+ }
11
+ Familia.config.encryption_keys = test_keys
12
+ Familia.config.current_key_version = :v1
13
+
14
+ class FreshKeyDerivationTest < Familia::Horreum
15
+ feature :encrypted_fields
16
+ identifier_field :user_id
17
+ field :user_id
18
+ encrypted_field :test_field
19
+ end
20
+
21
+ ## Single encrypt operation increments counter
22
+ Familia::Encryption.reset_derivation_count!
23
+ model = FreshKeyDerivationTest.new(user_id: 'test-encrypt-1')
24
+ model.test_field = 'test-value'
25
+ Familia::Encryption.derivation_count.value
26
+ #=> 1
27
+
28
+ ## Single decrypt operation increments counter again
29
+ Familia::Encryption.reset_derivation_count!
30
+ model = FreshKeyDerivationTest.new(user_id: 'test-decrypt-1')
31
+ model.test_field = 'test-value' # encrypt (1 derivation)
32
+ retrieved = model.test_field # decrypt (2 derivations)
33
+ [retrieved, Familia::Encryption.derivation_count.value]
34
+ #=> ['test-value', 2]
35
+
36
+ ## Multiple encrypt operations accumulate derivation calls
37
+ Familia::Encryption.reset_derivation_count!
38
+ model = FreshKeyDerivationTest.new(user_id: 'test-encrypt-multi')
39
+ 3.times { |i| model.test_field = "value-#{i}" }
40
+ Familia::Encryption.derivation_count.value
41
+ #=> 3
42
+
43
+ ## Multiple decrypt operations call derivation each time
44
+ Familia::Encryption.reset_derivation_count!
45
+ model = FreshKeyDerivationTest.new(user_id: 'test-decrypt-multi')
46
+ model.test_field = 'initial-value'
47
+ 3.times { model.test_field }
48
+ Familia::Encryption.derivation_count.value
49
+ #=> 4
50
+
51
+ ## Mixed encrypt/decrypt operations accumulate calls
52
+ Familia::Encryption.reset_derivation_count!
53
+ model = FreshKeyDerivationTest.new(user_id: 'test-mixed')
54
+ 2.times { |i| model.test_field = "mixed-#{i}" } # 2 encryptions
55
+ 2.times { model.test_field } # 2 decryptions
56
+ Familia::Encryption.derivation_count.value
57
+ #=> 4
58
+
59
+ ## Write-read pairs trigger derivation for each operation
60
+ Familia::Encryption.reset_derivation_count!
61
+ model = FreshKeyDerivationTest.new(user_id: 'test-pairs')
62
+ results = []
63
+ 5.times do |i|
64
+ model.test_field = "pair-#{i}" # encrypt
65
+ results << model.test_field # decrypt
66
+ end
67
+ [results.length, Familia::Encryption.derivation_count.value]
68
+ #=> [5, 10]
69
+
70
+ ## Different field values trigger fresh derivation each time
71
+ Familia::Encryption.reset_derivation_count!
72
+ model = FreshKeyDerivationTest.new(user_id: 'test-different-values')
73
+ model.test_field = 'first'
74
+ first_count = Familia::Encryption.derivation_count.value
75
+ model.test_field = 'second'
76
+ second_count = Familia::Encryption.derivation_count.value
77
+ model.test_field = 'third'
78
+ third_count = Familia::Encryption.derivation_count.value
79
+ [first_count, second_count, third_count]
80
+ #=> [1, 2, 3]
81
+
82
+ ## Verify no caching occurs across operations
83
+ Familia::Encryption.reset_derivation_count!
84
+ model = FreshKeyDerivationTest.new(user_id: 'test-no-cache')
85
+ values = ['alpha', 'beta', 'gamma']
86
+ operation_pairs = values.map do |val|
87
+ model.test_field = val # encrypt
88
+ retrieved = model.test_field # decrypt
89
+ [val, retrieved]
90
+ end
91
+ all_match = operation_pairs.all? { |pair| pair[0] == pair[1] }
92
+ [all_match, Familia::Encryption.derivation_count.value]
93
+ #=> [true, 6]
94
+
95
+ ## Empty string handling doesn't trigger derivation
96
+ Familia::Encryption.reset_derivation_count!
97
+ model = FreshKeyDerivationTest.new(user_id: 'test-empty')
98
+ model.test_field = ''
99
+ empty_result = model.test_field
100
+ [empty_result, Familia::Encryption.derivation_count.value]
101
+ #=> [nil, 0]
102
+
103
+ ## Nil values don't trigger derivation
104
+ Familia::Encryption.reset_derivation_count!
105
+ model = FreshKeyDerivationTest.new(user_id: 'test-nil')
106
+ model.test_field = nil
107
+ nil_result = model.test_field
108
+ [nil_result, Familia::Encryption.derivation_count.value]
109
+ #=> [nil, 0]
110
+
111
+ ## Key version rotation increments derivation count
112
+ Familia::Encryption.reset_derivation_count!
113
+ model = FreshKeyDerivationTest.new(user_id: 'test-rotation')
114
+ model.test_field = 'original' # v1 encrypt
115
+ Familia.config.current_key_version = :v2
116
+ model.test_field = 'updated' # v2 encrypt
117
+ retrieved = model.test_field # v2 decrypt
118
+ Familia::Encryption.derivation_count.value
119
+ #=> 3
120
+
121
+ Familia.config.encryption_keys = nil
122
+ Familia.config.current_key_version = nil
@@ -0,0 +1,163 @@
1
+ require_relative '../../helpers/test_helpers'
2
+ require 'base64'
3
+
4
+ # Setup encryption configuration
5
+ @test_keys = { v1: Base64.strict_encode64('a' * 32) }
6
+ Familia.config.encryption_keys = @test_keys
7
+ Familia.config.current_key_version = :v1
8
+
9
+ class BasicEncryptedModel < Familia::Horreum
10
+ feature :encrypted_fields
11
+ identifier_field :user_id
12
+ field :user_id
13
+ encrypted_field :secret_data
14
+ end
15
+
16
+ class MultiInstanceModel < Familia::Horreum
17
+ feature :encrypted_fields
18
+ identifier_field :user_id
19
+ field :user_id
20
+ encrypted_field :data
21
+ end
22
+
23
+ class NonceTestModel < Familia::Horreum
24
+ feature :encrypted_fields
25
+ identifier_field :user_id
26
+ field :user_id
27
+ encrypted_field :repeatable_data
28
+ end
29
+
30
+ class TimingTestModel < Familia::Horreum
31
+ feature :encrypted_fields
32
+ identifier_field :user_id
33
+ field :user_id
34
+ encrypted_field :timed_data
35
+ end
36
+
37
+ class MultiFieldModel < Familia::Horreum
38
+ feature :encrypted_fields
39
+ identifier_field :user_id
40
+ field :user_id
41
+ encrypted_field :field_a
42
+ encrypted_field :field_b
43
+ end
44
+
45
+ class AADTestModel < Familia::Horreum
46
+ feature :encrypted_fields
47
+ identifier_field :user_id
48
+ field :user_id
49
+ field :context_field
50
+ encrypted_field :aad_protected, aad_fields: [:context_field]
51
+ end
52
+
53
+ class NilTestModel < Familia::Horreum
54
+ feature :encrypted_fields
55
+ identifier_field :user_id
56
+ field :user_id
57
+ encrypted_field :optional_data
58
+ end
59
+
60
+ class PersistenceTestModel < Familia::Horreum
61
+ feature :encrypted_fields
62
+ identifier_field :user_id
63
+ field :user_id
64
+ encrypted_field :persistent_data
65
+ end
66
+
67
+ ## Basic encrypted field functionality works
68
+ model = BasicEncryptedModel.new(user_id: 'test-basic')
69
+ model.secret_data = 'confidential'
70
+ model.secret_data
71
+ #=> 'confidential'
72
+
73
+ ## Different instances derive keys independently
74
+ model1 = MultiInstanceModel.new(user_id: 'user-1')
75
+ model2 = MultiInstanceModel.new(user_id: 'user-2')
76
+ model1.data = 'secret-1'
77
+ model2.data = 'secret-2'
78
+ [model1.data, model2.data]
79
+ #=> ['secret-1', 'secret-2']
80
+
81
+ ## Same value encrypted multiple times produces different ciphertext
82
+ model = NonceTestModel.new(user_id: 'nonce-test')
83
+ model.repeatable_data = 'same-value'
84
+ first_internal = model.instance_variable_get(:@repeatable_data)
85
+ model.repeatable_data = 'same-value'
86
+ second_internal = model.instance_variable_get(:@repeatable_data)
87
+ first_internal != second_internal
88
+ #=> true
89
+
90
+ ## Decrypted values remain the same despite different internal storage
91
+ model = NonceTestModel.new(user_id: 'nonce-test-2')
92
+ model.repeatable_data = 'same-value'
93
+ model.repeatable_data
94
+ #=> 'same-value'
95
+
96
+ ## Fresh derivation verification through timing side-channel
97
+ @model = TimingTestModel.new(user_id: 'timing-test')
98
+ times = []
99
+ 10.times do |i|
100
+ start_time = Time.now
101
+ @model.timed_data = "test-value-#{i}"
102
+ @model.timed_data
103
+ times << (Time.now - start_time)
104
+ end
105
+ min_time = times.min
106
+ max_time = times.max
107
+ variance_ratio = max_time / min_time
108
+ variance_ratio < 3.0
109
+ #=> true
110
+
111
+ ## No cross-contamination between different field contexts
112
+ model = MultiFieldModel.new(user_id: 'multi-field')
113
+ model.field_a = 'value-a'
114
+ model.field_b = 'value-b'
115
+ internal_a = model.instance_variable_get(:@field_a)
116
+ internal_b = model.instance_variable_get(:@field_b)
117
+ internal_a != internal_b
118
+ #=> true
119
+
120
+ ## Decrypted values are correct for multiple fields
121
+ model = MultiFieldModel.new(user_id: 'multi-field-2')
122
+ model.field_a = 'value-a'
123
+ model.field_b = 'value-b'
124
+ [model.field_a, model.field_b]
125
+ #=> ['value-a', 'value-b']
126
+
127
+ ## AAD fields affect derivation context
128
+ model1 = AADTestModel.new(user_id: 'aad-test-1', context_field: 'context-a')
129
+ model2 = AADTestModel.new(user_id: 'aad-test-2', context_field: 'context-b')
130
+ model1.aad_protected = 'protected-data'
131
+ model2.aad_protected = 'protected-data'
132
+ internal1 = model1.instance_variable_get(:@aad_protected)
133
+ internal2 = model2.instance_variable_get(:@aad_protected)
134
+ internal1 != internal2
135
+ #=> true
136
+
137
+ ## AAD protected fields decrypt correctly
138
+ model1 = AADTestModel.new(user_id: 'aad-test-3', context_field: 'context-a')
139
+ model2 = AADTestModel.new(user_id: 'aad-test-4', context_field: 'context-b')
140
+ model1.aad_protected = 'protected-data'
141
+ model2.aad_protected = 'protected-data'
142
+ [model1.aad_protected, model2.aad_protected]
143
+ #=> ['protected-data', 'protected-data']
144
+
145
+ ## Memory efficiency - nil values not encrypted
146
+ model = NilTestModel.new(user_id: 'nil-test')
147
+ model.optional_data = nil
148
+ model.instance_variable_get(:@optional_data)
149
+ #=> nil
150
+
151
+ ## Empty string should be encrypted differently than nil
152
+ model = NilTestModel.new(user_id: 'nil-test-2')
153
+ model.optional_data = ''
154
+ internal_empty = model.instance_variable_get(:@optional_data)
155
+ internal_empty.nil?
156
+ #=> true
157
+
158
+ ## Consistent behavior across Ruby restart simulation
159
+ model = PersistenceTestModel.new(user_id: 'persistence-test')
160
+ model.persistent_data = 'data-to-persist'
161
+ Thread.current[:familia_request_cache] = nil if Thread.current[:familia_request_cache]
162
+ model.persistent_data
163
+ #=> 'data-to-persist'
@@ -0,0 +1,117 @@
1
+ # try/features/encryption_fields/key_rotation_try.rb
2
+
3
+ require 'base64'
4
+
5
+ require_relative '../../helpers/test_helpers'
6
+
7
+ # Setup multiple key versions for rotation testing
8
+ @test_keys = {
9
+ v1: Base64.strict_encode64('a' * 32),
10
+ v2: Base64.strict_encode64('b' * 32),
11
+ v3: Base64.strict_encode64('c' * 32)
12
+ }
13
+
14
+ class RotationTest < Familia::Horreum
15
+ feature :encrypted_fields
16
+ identifier_field :id
17
+ field :id
18
+ encrypted_field :secret
19
+ end
20
+
21
+ # Data encrypted with v1 can still be decrypted after rotation to v2
22
+ Familia.config.encryption_keys = { v1: @test_keys[:v1] }
23
+ Familia.config.current_key_version = :v1
24
+
25
+ @model = RotationTest.new(id: 'rot-1')
26
+ @model.secret = 'original-secret'
27
+ @v1_ciphertext = @model.instance_variable_get(:@secret)
28
+
29
+ # Rotate to v2 with both keys available
30
+ Familia.config.encryption_keys = { v1: @test_keys[:v1], v2: @test_keys[:v2] }
31
+ Familia.config.current_key_version = :v2
32
+
33
+ ## Manually set the old ciphertext and try to decrypt
34
+ @model.instance_variable_set(:@secret, @v1_ciphertext)
35
+ @model.secret
36
+ #=> 'original-secret'
37
+
38
+ ## New data encrypts with current key version (v2)
39
+ @model.secret = 'updated-secret'
40
+ @v2_ciphertext = @model.instance_variable_get(:@secret)
41
+ @parsed_v2 = JSON.parse(@v2_ciphertext, symbolize_names: true)
42
+ @parsed_v2[:key_version]
43
+ #=> "v2"
44
+
45
+ ## Missing historical key causes decryption failure
46
+ Familia.config.encryption_keys = { v3: @test_keys[:v3] }
47
+ Familia.config.current_key_version = :v3
48
+ @model.instance_variable_set(:@secret, @v1_ciphertext)
49
+ begin
50
+ @model.secret
51
+ false
52
+ rescue Familia::EncryptionError => e
53
+ e.message.include?('No key for version: v1')
54
+ end
55
+ #=> true
56
+
57
+ ## Derivation counter increments during key rotation operations
58
+ Familia::Encryption.reset_derivation_count!
59
+ Familia.config.encryption_keys = @test_keys
60
+ Familia.config.current_key_version = :v1
61
+ #=> :v1
62
+
63
+ ## Derivation counter increments
64
+ @rotation_model = RotationTest.new(id: 'rot-counter')
65
+ @rotation_model.secret = 'test1' # v1 encrypt
66
+ Familia::Encryption.derivation_count.value
67
+ #=> 1
68
+
69
+ ## Key rotation to v2 for new encryption
70
+ Familia.config.current_key_version = :v2
71
+ @rotation_model.secret = 'test2' # v2 encrypt
72
+ Familia::Encryption.derivation_count.value
73
+ #=> 2
74
+
75
+ ## Decryption with v2 key
76
+ @retrieved = @rotation_model.secret # v2 decrypt
77
+ Familia::Encryption.derivation_count.value
78
+ #=> 3
79
+
80
+ ## Key rotation to v3 for new encryption
81
+ Familia.config.current_key_version = :v3
82
+ @rotation_model.secret = 'test3' # v3 encrypt
83
+ Familia::Encryption.derivation_count.value
84
+ #=> 4
85
+
86
+ ## Multiple key versions coexist for backward compatibility
87
+ Familia.config.encryption_keys = { v1: @test_keys[:v1], v2: @test_keys[:v2], v3: @test_keys[:v3] }
88
+ Familia.config.current_key_version = :v2
89
+
90
+ @multi_model = RotationTest.new(id: 'multi-key')
91
+
92
+ # Create data with v1
93
+ Familia.config.current_key_version = :v1
94
+ @multi_model.secret = 'v1-data'
95
+ @v1_data = @multi_model.instance_variable_get(:@secret)
96
+
97
+ # Create data with v3
98
+ Familia.config.current_key_version = :v3
99
+ @multi_model.secret = 'v3-data'
100
+ @v3_data = @multi_model.instance_variable_get(:@secret)
101
+
102
+ # Switch back to v2 as current
103
+ Familia.config.current_key_version = :v2
104
+
105
+ # Can still decrypt v1 data
106
+ @multi_model.instance_variable_set(:@secret, @v1_data)
107
+ @multi_model.secret
108
+ #=> 'v1-data'
109
+
110
+ ## Can still decrypt v3 data with v2 as current key
111
+ @multi_model.instance_variable_set(:@secret, @v3_data)
112
+ @multi_model.secret
113
+ #=> 'v3-data'
114
+
115
+ # Cleanup
116
+ Familia.config.encryption_keys = nil
117
+ Familia.config.current_key_version = nil
@@ -0,0 +1,37 @@
1
+ # try/features/encryption_fields/memory_security_try.rb
2
+
3
+ require 'base64'
4
+ require_relative '../../helpers/test_helpers'
5
+
6
+ test_keys = {
7
+ v1: Base64.strict_encode64('a' * 32),
8
+ }
9
+ Familia.config.encryption_keys = test_keys
10
+ Familia.config.current_key_version = :v1
11
+
12
+ ## Keys are wiped from memory after use
13
+ # Note: This is difficult to test directly, but we can verify
14
+ # the secure_wipe method is called
15
+
16
+ class WipeTest < Familia::Horreum
17
+ feature :encrypted_fields
18
+ identifier_field :id
19
+ field :id
20
+ encrypted_field :secret
21
+ end
22
+
23
+ # Monkey-patch to track wipe calls
24
+ wipe_calls = 0
25
+ original_wipe = Familia::Encryption.singleton_method(:secure_wipe)
26
+ Familia::Encryption.define_singleton_method(:secure_wipe) do |key|
27
+ wipe_calls += 1
28
+ original_wipe.call(key)
29
+ end
30
+
31
+ model = WipeTest.new(id: 'wipe-1')
32
+ model.secret = 'test'
33
+ model.secret
34
+
35
+ # Should wipe master key after each derivation (2 operations = 2 wipes)
36
+ wipe_calls >= 2
37
+ #=> true
@@ -0,0 +1,23 @@
1
+ # try/features/encryption_fields/missing_current_key_version_try.rb
2
+
3
+ require 'base64'
4
+
5
+ require_relative '../../helpers/test_helpers'
6
+
7
+ # This tryouts file is based on the premise that there is no current key
8
+ # version set. This is a global setting so if other tryouts rely on
9
+ # having it, they will fail unless they set it for themselves.
10
+ Familia.config.current_key_version = nil
11
+
12
+ class NoCurrentKeyVersionTest < Familia::Horreum
13
+ feature :encrypted_fields
14
+ identifier_field :user_id
15
+ field :user_id
16
+ encrypted_field :test_field
17
+ end
18
+
19
+ ## Attempt to encrypt will raise an encryption error
20
+ model_student = NoCurrentKeyVersionTest.new(user_id: 'derivation-test')
21
+ model_student.test_field = 'test-value'
22
+ #=!> Familia::EncryptionError
23
+ #=!> error.message.include?('Key version cannot be nil')