familia 2.0.0.pre5 → 2.0.0.pre7

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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -10
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +4 -3
  9. data/docs/wiki/API-Reference.md +95 -18
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +631 -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 +82 -15
  17. data/docs/wiki/Implementation-Guide.md +126 -33
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/Relationships-Guide.md +684 -0
  20. data/docs/wiki/Security-Model.md +65 -25
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/examples/bit_encoding_integration.rb +237 -0
  23. data/examples/redis_command_validation_example.rb +231 -0
  24. data/examples/relationships_basic.rb +273 -0
  25. data/lib/familia/base.rb +1 -1
  26. data/lib/familia/connection.rb +3 -3
  27. data/lib/familia/data_type/types/counter.rb +38 -0
  28. data/lib/familia/data_type/types/hashkey.rb +18 -0
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/data_type/types/string.rb +9 -2
  31. data/lib/familia/data_type.rb +9 -6
  32. data/lib/familia/encryption/encrypted_data.rb +137 -0
  33. data/lib/familia/encryption/manager.rb +21 -4
  34. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  35. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  36. data/lib/familia/encryption.rb +1 -1
  37. data/lib/familia/errors.rb +17 -3
  38. data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
  39. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  40. data/lib/familia/features/encrypted_fields.rb +413 -4
  41. data/lib/familia/features/expiration.rb +319 -33
  42. data/lib/familia/features/quantization.rb +385 -44
  43. data/lib/familia/features/relationships/cascading.rb +438 -0
  44. data/lib/familia/features/relationships/indexing.rb +370 -0
  45. data/lib/familia/features/relationships/membership.rb +503 -0
  46. data/lib/familia/features/relationships/permission_management.rb +264 -0
  47. data/lib/familia/features/relationships/querying.rb +620 -0
  48. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  49. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  50. data/lib/familia/features/relationships/tracking.rb +379 -0
  51. data/lib/familia/features/relationships.rb +466 -0
  52. data/lib/familia/features/safe_dump.rb +1 -1
  53. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  54. data/lib/familia/features/transient_fields.rb +192 -10
  55. data/lib/familia/features.rb +2 -1
  56. data/lib/familia/field_type.rb +5 -2
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  58. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  63. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
  64. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +17 -17
  67. data/lib/familia/validation/command_recorder.rb +336 -0
  68. data/lib/familia/validation/expectations.rb +519 -0
  69. data/lib/familia/validation/test_helpers.rb +443 -0
  70. data/lib/familia/validation/validator.rb +412 -0
  71. data/lib/familia/validation.rb +140 -0
  72. data/lib/familia/version.rb +1 -1
  73. data/lib/familia.rb +1 -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 -4
  77. data/try/core/familia_try.rb +1 -1
  78. data/try/core/persistence_operations_try.rb +297 -0
  79. data/try/data_types/counter_try.rb +93 -0
  80. data/try/data_types/lock_try.rb +133 -0
  81. data/try/debugging/debug_aad_process.rb +82 -0
  82. data/try/debugging/debug_concealed_internal.rb +59 -0
  83. data/try/debugging/debug_concealed_reveal.rb +61 -0
  84. data/try/debugging/debug_context_aad.rb +68 -0
  85. data/try/debugging/debug_context_simple.rb +80 -0
  86. data/try/debugging/debug_cross_context.rb +62 -0
  87. data/try/debugging/debug_database_load.rb +64 -0
  88. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  89. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  90. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  91. data/try/debugging/debug_field_decrypt.rb +74 -0
  92. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  93. data/try/debugging/debug_load_path.rb +66 -0
  94. data/try/debugging/debug_method_definition.rb +46 -0
  95. data/try/debugging/debug_method_resolution.rb +41 -0
  96. data/try/debugging/debug_minimal.rb +24 -0
  97. data/try/debugging/debug_provider.rb +68 -0
  98. data/try/debugging/debug_secure_behavior.rb +73 -0
  99. data/try/debugging/debug_string_class.rb +46 -0
  100. data/try/debugging/debug_test.rb +46 -0
  101. data/try/debugging/debug_test_design.rb +80 -0
  102. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  103. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  104. data/try/edge_cases/string_coercion_try.rb +2 -0
  105. data/try/encryption/encryption_core_try.rb +6 -4
  106. data/try/features/categorical_permissions_try.rb +515 -0
  107. data/try/features/encrypted_fields_core_try.rb +19 -11
  108. data/try/features/encrypted_fields_integration_try.rb +66 -70
  109. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  110. data/try/features/encrypted_fields_security_try.rb +151 -144
  111. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  112. data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
  113. data/try/features/encryption_fields/context_isolation_try.rb +30 -8
  114. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  115. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  116. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  117. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  118. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  119. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  120. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  121. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  122. data/try/features/feature_dependencies_try.rb +3 -3
  123. data/try/features/relationships_edge_cases_try.rb +145 -0
  124. data/try/features/relationships_performance_minimal_try.rb +132 -0
  125. data/try/features/relationships_performance_simple_try.rb +155 -0
  126. data/try/features/relationships_performance_try.rb +420 -0
  127. data/try/features/relationships_performance_working_try.rb +144 -0
  128. data/try/features/relationships_try.rb +237 -0
  129. data/try/features/safe_dump_try.rb +3 -0
  130. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  131. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  132. data/try/features/transient_fields_core_try.rb +1 -1
  133. data/try/features/transient_fields_integration_try.rb +1 -1
  134. data/try/helpers/test_helpers.rb +26 -1
  135. data/try/horreum/base_try.rb +14 -8
  136. data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
  137. data/try/horreum/initialization_try.rb +1 -1
  138. data/try/horreum/relations_try.rb +2 -2
  139. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  140. data/try/horreum/serialization_try.rb +39 -4
  141. data/try/models/customer_safe_dump_try.rb +1 -1
  142. data/try/models/customer_try.rb +1 -1
  143. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  144. data/try/validation/command_validation_try.rb.disabled +207 -0
  145. data/try/validation/performance_validation_try.rb.disabled +324 -0
  146. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  147. metadata +81 -12
  148. data/TEST_COVERAGE.md +0 -40
  149. data/lib/familia/features/relatable_objects.rb +0 -125
  150. data/lib/familia/horreum/serialization.rb +0 -473
  151. data/try/features/relatable_objects_try.rb +0 -220
@@ -27,12 +27,14 @@ module Familia
27
27
  attr_writer :logical_database, :uri
28
28
  end
29
29
 
30
+ # DataType::ClassMethods
31
+ #
30
32
  module ClassMethods
31
33
  # To be called inside every class that inherits DataType
32
34
  # +methname+ is the term used for the class and instance methods
33
35
  # that are created for the given +klass+ (e.g. set, list, etc)
34
36
  def register(klass, methname)
35
- Familia.ld "[#{self}] Registering #{klass} as #{methname.inspect}"
37
+ Familia.trace :REGISTER, nil, "[#{self}] Registering #{klass} as #{methname.inspect}", caller(1..1) if Familia.debug?
36
38
 
37
39
  @registered_types[methname] = klass
38
40
  end
@@ -57,16 +59,16 @@ module Familia
57
59
  end
58
60
 
59
61
  def valid_keys_only(opts)
60
- opts.select { |k, _| DataType.valid_options.include? k }
62
+ opts.slice(*DataType.valid_options)
61
63
  end
62
64
 
63
- def has_relations?
64
- @has_relations ||= false
65
+ def relations?
66
+ @has_relations ||= false # rubocop:disable ThreadSafety/ClassInstanceVariable
65
67
  end
66
68
  end
67
69
  extend ClassMethods
68
70
 
69
- attr_reader :keystring, :parent, :opts
71
+ attr_reader :keystring, :opts
70
72
  attr_writer :dump_method, :load_method
71
73
 
72
74
  # +keystring+: If parent is set, this will be used as the suffix
@@ -115,7 +117,7 @@ module Familia
115
117
  # this point. This would result in a Familia::Problem being raised. So
116
118
  # to be on the safe-side here until we have a better understanding of
117
119
  # the issue, we'll just log the class name for each key-value pair.
118
- Familia.ld " [setting] #{k} #{v.class}"
120
+ Familia.trace :SETTING, nil, " [setting] #{k} #{v.class}", caller(1..1) if Familia.debug?
119
121
  send(:"#{k}=", v) if respond_to? :"#{k}="
120
122
  end
121
123
 
@@ -199,6 +201,7 @@ module Familia
199
201
  @opts[:parent]
200
202
  end
201
203
 
204
+
202
205
  def logical_database
203
206
  @opts[:logical_database] || self.class.logical_database
204
207
  end
@@ -0,0 +1,137 @@
1
+ # lib/familia/encryption/encrypted_data.rb
2
+
3
+ module Familia
4
+ module Encryption
5
+ EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version) do
6
+ # Class methods for parsing and validation
7
+ def self.valid?(json_string)
8
+ return true if json_string.nil? # Allow nil values
9
+ return false unless json_string.kind_of?(::String)
10
+
11
+ begin
12
+ parsed = JSON.parse(json_string, symbolize_names: true)
13
+ return false unless parsed.is_a?(Hash)
14
+
15
+ # Check for required fields
16
+ required_fields = [:algorithm, :nonce, :ciphertext, :auth_tag, :key_version]
17
+ result = required_fields.all? { |field| parsed.key?(field) }
18
+ Familia.ld "[valid?] result: #{result}, parsed: #{parsed}, required: #{required_fields}"
19
+ result
20
+ rescue JSON::ParserError => e
21
+ Familia.ld "[valid?] JSON error: #{e.message}"
22
+ false
23
+ end
24
+ end
25
+
26
+ def self.validate!(json_string)
27
+ return nil if json_string.nil?
28
+
29
+ unless json_string.kind_of?(::String)
30
+ raise EncryptionError, "Expected JSON string, got #{json_string.class}"
31
+ end
32
+
33
+ begin
34
+ parsed = JSON.parse(json_string, symbolize_names: true)
35
+ rescue JSON::ParserError => e
36
+ raise EncryptionError, "Invalid JSON structure: #{e.message}"
37
+ end
38
+
39
+ unless parsed.is_a?(Hash)
40
+ raise EncryptionError, "Expected JSON object, got #{parsed.class}"
41
+ end
42
+
43
+ required_fields = [:algorithm, :nonce, :ciphertext, :auth_tag, :key_version]
44
+ missing_fields = required_fields.reject { |field| parsed.key?(field) }
45
+
46
+ unless missing_fields.empty?
47
+ raise EncryptionError, "Missing required fields: #{missing_fields.join(', ')}"
48
+ end
49
+
50
+ new(**parsed)
51
+ end
52
+
53
+ def self.from_json(json_string)
54
+ validate!(json_string)
55
+ end
56
+
57
+ # Instance methods for decryptability validation
58
+ def decryptable?
59
+ return false unless algorithm && nonce && ciphertext && auth_tag && key_version
60
+
61
+ # Ensure Registry is set up before checking algorithms
62
+ Registry.setup! if Registry.providers.empty?
63
+
64
+ # Check if algorithm is supported
65
+ return false unless Registry.providers.key?(algorithm)
66
+
67
+ # Validate Base64 encoding of binary fields
68
+ begin
69
+ Base64.strict_decode64(nonce)
70
+ Base64.strict_decode64(ciphertext)
71
+ Base64.strict_decode64(auth_tag)
72
+ rescue ArgumentError
73
+ return false
74
+ end
75
+
76
+ true
77
+ end
78
+
79
+ def validate_decryptable!
80
+ unless algorithm
81
+ raise EncryptionError, "Missing algorithm field"
82
+ end
83
+
84
+ # Ensure Registry is set up before checking algorithms
85
+ Registry.setup! if Registry.providers.empty?
86
+
87
+ unless Registry.providers.key?(algorithm)
88
+ raise EncryptionError, "Unsupported algorithm: #{algorithm}"
89
+ end
90
+
91
+ unless nonce && ciphertext && auth_tag && key_version
92
+ missing = []
93
+ missing << 'nonce' unless nonce
94
+ missing << 'ciphertext' unless ciphertext
95
+ missing << 'auth_tag' unless auth_tag
96
+ missing << 'key_version' unless key_version
97
+ raise EncryptionError, "Missing required fields: #{missing.join(', ')}"
98
+ end
99
+
100
+ # Get the provider for size validation
101
+ provider = Registry.providers[algorithm]
102
+
103
+ # Validate Base64 encoding and sizes
104
+ begin
105
+ decoded_nonce = Base64.strict_decode64(nonce)
106
+ if decoded_nonce.bytesize != provider.nonce_size
107
+ raise EncryptionError, "Invalid nonce size: expected #{provider.nonce_size}, got #{decoded_nonce.bytesize}"
108
+ end
109
+ rescue ArgumentError
110
+ raise EncryptionError, "Invalid Base64 encoding in nonce field"
111
+ end
112
+
113
+ begin
114
+ Base64.strict_decode64(ciphertext) # ciphertext can be variable size
115
+ rescue ArgumentError
116
+ raise EncryptionError, "Invalid Base64 encoding in ciphertext field"
117
+ end
118
+
119
+ begin
120
+ decoded_auth_tag = Base64.strict_decode64(auth_tag)
121
+ if decoded_auth_tag.bytesize != provider.auth_tag_size
122
+ raise EncryptionError, "Invalid auth_tag size: expected #{provider.auth_tag_size}, got #{decoded_auth_tag.bytesize}"
123
+ end
124
+ rescue ArgumentError
125
+ raise EncryptionError, "Invalid Base64 encoding in auth_tag field"
126
+ end
127
+
128
+ # Validate that the key version exists
129
+ unless Familia.config.encryption_keys&.key?(key_version.to_sym)
130
+ raise EncryptionError, "No key for version: #{key_version}"
131
+ end
132
+
133
+ self
134
+ end
135
+ end
136
+ end
137
+ end
@@ -33,23 +33,28 @@ module Familia
33
33
  def decrypt(encrypted_json, context:, additional_data: nil)
34
34
  return nil if encrypted_json.nil? || encrypted_json.empty?
35
35
 
36
+ # Increment counter immediately to track all decryption attempts, even failed ones
37
+ Familia::Encryption.derivation_count.increment
38
+
36
39
  begin
37
40
  data = Familia::Encryption::EncryptedData.new(**JSON.parse(encrypted_json, symbolize_names: true))
38
41
 
39
42
  # Validate algorithm support
40
43
  provider = Registry.get(data.algorithm)
41
- key = derive_key(context, version: data.key_version, provider: provider)
44
+ key = derive_key_without_increment(context, version: data.key_version, provider: provider)
42
45
 
43
46
  # Safely decode and validate sizes
44
47
  nonce = decode_and_validate(data.nonce, provider.nonce_size, 'nonce')
45
- ciphertext = Base64.strict_decode64(data.ciphertext)
48
+ ciphertext = decode_and_validate_ciphertext(data.ciphertext)
46
49
  auth_tag = decode_and_validate(data.auth_tag, provider.auth_tag_size, 'auth_tag')
47
50
 
48
51
  provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
49
52
  rescue EncryptionError
50
53
  raise
51
- rescue StandardError
52
- raise EncryptionError, 'Decryption failed'
54
+ rescue JSON::ParserError => e
55
+ raise EncryptionError, "Invalid JSON structure: #{e.message}"
56
+ rescue StandardError => e
57
+ raise EncryptionError, "Decryption failed: #{e.message}"
53
58
  end
54
59
  ensure
55
60
  Familia::Encryption.secure_wipe(key) if key
@@ -61,12 +66,24 @@ module Familia
61
66
  decoded = Base64.strict_decode64(encoded)
62
67
  raise EncryptionError, 'Invalid encrypted data' unless decoded.bytesize == expected_size
63
68
  decoded
69
+ rescue ArgumentError => e
70
+ raise EncryptionError, "Invalid Base64 encoding in #{component} field"
71
+ end
72
+
73
+ def decode_and_validate_ciphertext(encoded)
74
+ Base64.strict_decode64(encoded)
75
+ rescue ArgumentError => e
76
+ raise EncryptionError, "Invalid Base64 encoding in ciphertext field"
64
77
  end
65
78
 
66
79
  def derive_key(context, version: nil, provider: nil)
67
80
  # Increment counter to prove no caching is happening
68
81
  Familia::Encryption.derivation_count.increment
69
82
 
83
+ derive_key_without_increment(context, version: version, provider: provider)
84
+ end
85
+
86
+ def derive_key_without_increment(context, version: nil, provider: nil)
70
87
  # Use provided provider or fall back to instance provider
71
88
  provider ||= @provider
72
89
 
@@ -87,6 +87,26 @@ module Familia
87
87
  key&.clear
88
88
  end
89
89
 
90
+ def self.nonce_size
91
+ NONCE_SIZE
92
+ end
93
+
94
+ def self.auth_tag_size
95
+ AUTH_TAG_SIZE
96
+ end
97
+
98
+ def nonce_size
99
+ NONCE_SIZE
100
+ end
101
+
102
+ def auth_tag_size
103
+ AUTH_TAG_SIZE
104
+ end
105
+
106
+ def algorithm
107
+ ALGORITHM
108
+ end
109
+
90
110
  private
91
111
 
92
112
  def create_cipher(mode)
@@ -106,6 +106,26 @@ module Familia
106
106
  key&.clear
107
107
  end
108
108
 
109
+ def self.nonce_size
110
+ NONCE_SIZE
111
+ end
112
+
113
+ def self.auth_tag_size
114
+ AUTH_TAG_SIZE
115
+ end
116
+
117
+ def nonce_size
118
+ NONCE_SIZE
119
+ end
120
+
121
+ def auth_tag_size
122
+ AUTH_TAG_SIZE
123
+ end
124
+
125
+ def algorithm
126
+ ALGORITHM
127
+ end
128
+
109
129
  private
110
130
 
111
131
  def validate_key_length!(key)
@@ -10,12 +10,12 @@ require_relative 'encryption/providers/xchacha20_poly1305_provider'
10
10
  require_relative 'encryption/providers/aes_gcm_provider'
11
11
  require_relative 'encryption/registry'
12
12
  require_relative 'encryption/manager'
13
+ require_relative 'encryption/encrypted_data'
13
14
 
14
15
  module Familia
15
16
  class EncryptionError < StandardError; end
16
17
 
17
18
  module Encryption
18
- EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version)
19
19
 
20
20
  # Smart facade with provider selection and field-specific encryption
21
21
  #
@@ -34,8 +34,8 @@ module Familia
34
34
  # Set Familia.connection_provider or use middleware to provide connections.
35
35
  class NoConnectionAvailable < Problem; end
36
36
 
37
- # Raised when attempting to refresh an object whose key doesn't exist in Redis
38
- class KeyNotFoundError < Problem
37
+ # Raised when attempting to refresh an object whose key doesn't exist in the database
38
+ class KeyNotFoundError < NonUniqueKey
39
39
  attr_reader :key
40
40
 
41
41
  def initialize(key)
@@ -44,7 +44,21 @@ module Familia
44
44
  end
45
45
 
46
46
  def message
47
- "Key not found in Redis: #{key}"
47
+ "Key not found: #{key}"
48
+ end
49
+ end
50
+
51
+ # Raised when attempting to create an object that already exists in the database
52
+ class RecordExistsError < NonUniqueKey
53
+ attr_reader :key
54
+
55
+ def initialize(key)
56
+ @key = key
57
+ super
58
+ end
59
+
60
+ def message
61
+ "Key already exists: #{key}"
48
62
  end
49
63
  end
50
64
  end
@@ -0,0 +1,293 @@
1
+ # lib/familia/features/encrypted_fields/concealed_string.rb
2
+
3
+ # ConcealedString
4
+ #
5
+ # A secure wrapper for encrypted field values that prevents accidental
6
+ # plaintext leakage through serialization, logging, or debugging.
7
+ #
8
+ # Unlike RedactedString (which wraps plaintext), ConcealedString wraps
9
+ # encrypted data and provides controlled decryption through the .reveal API.
10
+ #
11
+ # Security Model:
12
+ # - Contains encrypted JSON data, never plaintext
13
+ # - Requires explicit .reveal { } for decryption and plaintext access
14
+ # - ALL serialization methods return '[CONCEALED]' to prevent leakage
15
+ # - Maintains encryption context for proper AAD handling
16
+ # - Thread-safe and supports concurrent access
17
+ #
18
+ # Key Security Features:
19
+ # 1. Universal Serialization Safety - ALL to_* methods protected
20
+ # 2. Debugging Safety - inspect, logging, console output shows [CONCEALED]
21
+ # 3. Exception Safety - never leaks plaintext in error messages
22
+ # 4. Future-proof - any new serialization method automatically safe
23
+ # 5. Memory Clearing - best-effort encrypted data clearing
24
+ #
25
+ # Critical Design Principles:
26
+ # - Secure by default - no auto-decryption anywhere
27
+ # - Explicit decryption - .reveal required for plaintext access
28
+ # - Comprehensive protection - covers ALL serialization paths
29
+ # - Auditable access - easy to grep for .reveal usage
30
+ #
31
+ # Example Usage:
32
+ # user = User.new
33
+ # user.secret_data = "sensitive info" # Encrypts and wraps
34
+ # user.secret_data # Returns ConcealedString
35
+ # user.secret_data.reveal { |plain| ... } # Explicit decryption
36
+ # user.to_h # Safe - contains [CONCEALED]
37
+ # user.to_json # Safe - contains [CONCEALED]
38
+ #
39
+ class ConcealedString
40
+ # Create a concealed string wrapper
41
+ #
42
+ # @param encrypted_data [String] The encrypted JSON data
43
+ # @param record [Familia::Horreum] The record instance for context
44
+ # @param field_type [EncryptedFieldType] The field type for decryption
45
+ #
46
+ def initialize(encrypted_data, record, field_type)
47
+ @encrypted_data = encrypted_data.freeze
48
+ @record = record
49
+ @field_type = field_type
50
+ @cleared = false
51
+
52
+ # Parse and validate the encrypted data structure
53
+ if @encrypted_data
54
+ begin
55
+ @encrypted_data_obj = Familia::Encryption::EncryptedData.from_json(@encrypted_data)
56
+ # Validate that the encrypted data is decryptable (algorithm supported, etc.)
57
+ @encrypted_data_obj.validate_decryptable!
58
+ rescue Familia::EncryptionError => e
59
+ raise Familia::EncryptionError, e.message
60
+ rescue StandardError => e
61
+ raise Familia::EncryptionError, "Invalid encrypted data: #{e.message}"
62
+ end
63
+ end
64
+
65
+ ObjectSpace.define_finalizer(self, self.class.finalizer_proc(@encrypted_data))
66
+ end
67
+
68
+ # Primary API: reveal the decrypted plaintext in a controlled block
69
+ #
70
+ # This is the ONLY way to access plaintext from encrypted fields.
71
+ # The plaintext is decrypted fresh each time using the current
72
+ # record state and AAD context.
73
+ #
74
+ # Security Warning: Avoid operations inside the block that create
75
+ # uncontrolled copies of the plaintext (dup, interpolation, etc.)
76
+ #
77
+ # @yield [String] The decrypted plaintext value
78
+ # @return [Object] The return value of the block
79
+ #
80
+ # Example:
81
+ # user.api_token.reveal do |token|
82
+ # HTTP.post('/api', headers: { 'X-Token' => token })
83
+ # end
84
+ #
85
+ def reveal
86
+ raise ArgumentError, 'Block required for reveal' unless block_given?
87
+ raise SecurityError, 'Encrypted data already cleared' if cleared?
88
+ raise SecurityError, 'No encrypted data to reveal' if @encrypted_data.nil?
89
+
90
+ # Decrypt using current record context and AAD
91
+ plaintext = @field_type.decrypt_value(@record, @encrypted_data)
92
+ yield plaintext
93
+ end
94
+
95
+ # Validate that this ConcealedString belongs to the given record context
96
+ #
97
+ # This prevents cross-context attacks where encrypted data is moved between
98
+ # different records or field contexts. While moving ConcealedString objects
99
+ # manually is not a normal use case, this provides defense in depth.
100
+ #
101
+ # @param expected_record [Familia::Horreum] The record that should own this data
102
+ # @param expected_field_name [Symbol] The field name that should own this data
103
+ # @return [Boolean] true if contexts match, false otherwise
104
+ #
105
+ def belongs_to_context?(expected_record, expected_field_name)
106
+ return false if @record.nil? || @field_type.nil?
107
+
108
+ @record.instance_of?(expected_record.class) &&
109
+ @record.identifier == expected_record.identifier &&
110
+ @field_type.instance_variable_get(:@name) == expected_field_name
111
+ end
112
+
113
+ # Clear the encrypted data from memory
114
+ #
115
+ # Safe to call multiple times. This provides best-effort memory
116
+ # clearing within Ruby's limitations.
117
+ #
118
+ def clear!
119
+ return if @cleared
120
+
121
+ @encrypted_data = nil
122
+ @record = nil
123
+ @field_type = nil
124
+ @cleared = true
125
+ freeze
126
+ end
127
+
128
+ # Check if the encrypted data has been cleared
129
+ #
130
+ # @return [Boolean] true if cleared, false otherwise
131
+ #
132
+ def cleared?
133
+ @cleared
134
+ end
135
+
136
+ def empty?
137
+ @encrypted_data.to_s.empty?
138
+ end
139
+
140
+ # Returns true when it's literally the same object, otherwise false.
141
+ # This prevents timing attacks where an attacker could potentially
142
+ # infer information about the secret value through comparison timing
143
+ def ==(other)
144
+ object_id.equal?(other.object_id) # same object
145
+ end
146
+ alias eql? ==
147
+
148
+ # Access the encrypted data for database storage
149
+ #
150
+ # This method is used internally by the field type system
151
+ # for persisting the encrypted data to the database.
152
+ #
153
+ # @return [String, nil] The encrypted JSON data
154
+ #
155
+ def encrypted_value
156
+ @encrypted_data
157
+ end
158
+
159
+ # Prevent accidental exposure through string conversion and serialization
160
+ #
161
+ # Ruby has two string conversion methods with different purposes:
162
+ # - to_s: explicit conversion (obj.to_s, string interpolation "#{obj}")
163
+ # - to_str: implicit coercion (File.read(obj), "prefix" + obj)
164
+ #
165
+ # We implement to_s for safe logging/debugging but deliberately omit to_str
166
+ # to prevent encrypted data from being used where strings are expected.
167
+ #
168
+ def to_s
169
+ '[CONCEALED]'
170
+ end
171
+
172
+ # String methods that should return safe concealed values
173
+ def upcase
174
+ '[CONCEALED]'
175
+ end
176
+
177
+ def downcase
178
+ '[CONCEALED]'
179
+ end
180
+
181
+ def length
182
+ 11 # Fixed concealed length to match '[CONCEALED]' length
183
+ end
184
+
185
+ def size
186
+ length
187
+ end
188
+
189
+ def present?
190
+ true # Always return true since encrypted data exists
191
+ end
192
+
193
+ def blank?
194
+ false # Never blank if encrypted data exists
195
+ end
196
+
197
+ # String concatenation operations return concealed result
198
+ def +(_other)
199
+ '[CONCEALED]'
200
+ end
201
+
202
+ def concat(_other)
203
+ '[CONCEALED]'
204
+ end
205
+
206
+ # Handle coercion for concatenation like "string" + concealed
207
+ def coerce(other)
208
+ if other.is_a?(String)
209
+ ['[CONCEALED]', '[CONCEALED]']
210
+ else
211
+ [other, '[CONCEALED]']
212
+ end
213
+ end
214
+
215
+ # String pattern matching methods
216
+ def strip
217
+ '[CONCEALED]'
218
+ end
219
+
220
+ def gsub(*)
221
+ '[CONCEALED]'
222
+ end
223
+
224
+ def include?(_substring)
225
+ false # Never reveal substring presence
226
+ end
227
+
228
+ # Enumerable methods for safety
229
+ def map
230
+ yield '[CONCEALED]' if block_given?
231
+ ['[CONCEALED]']
232
+ end
233
+
234
+ def each
235
+ yield '[CONCEALED]' if block_given?
236
+ self
237
+ end
238
+
239
+ # Safe representation for debugging and console output
240
+ def inspect
241
+ '[CONCEALED]'
242
+ end
243
+
244
+ # Hash/Array serialization safety
245
+ def to_h
246
+ '[CONCEALED]'
247
+ end
248
+
249
+ def to_a
250
+ ['[CONCEALED]']
251
+ end
252
+
253
+ # Consistent hash to prevent timing attacks
254
+ def hash
255
+ ConcealedString.hash
256
+ end
257
+
258
+ # Pattern matching safety (Ruby 3.0+)
259
+ def deconstruct
260
+ ['[CONCEALED]']
261
+ end
262
+
263
+ def deconstruct_keys(*)
264
+ { concealed: true }
265
+ end
266
+
267
+ # Prevent exposure in JSON serialization
268
+ def to_json(*)
269
+ '"[CONCEALED]"'
270
+ end
271
+
272
+ # Prevent exposure in Rails serialization (as_json -> to_json)
273
+ def as_json(*)
274
+ '[CONCEALED]'
275
+ end
276
+
277
+ # Finalizer to attempt memory cleanup
278
+ def self.finalizer_proc(encrypted_data)
279
+ proc do
280
+ # Best effort cleanup - Ruby doesn't guarantee memory security
281
+ # Only clear if not frozen to avoid FrozenError
282
+ encrypted_data.clear if encrypted_data.respond_to?(:clear) && !encrypted_data.frozen?
283
+ end
284
+ end
285
+
286
+ private
287
+
288
+ # Check if a string looks like encrypted JSON data
289
+ def encrypted_json?(data)
290
+ Familia::Encryption::EncryptedData.valid?(data)
291
+ end
292
+
293
+ end