Class: ConcealedString
- Inherits:
-
Object
- Object
- ConcealedString
- Defined in:
- lib/familia/features/encrypted_fields/concealed_string.rb
Overview
ConcealedString
A secure wrapper for encrypted field values that prevents accidental plaintext leakage through serialization, logging, or debugging.
Unlike RedactedString (which wraps plaintext), ConcealedString wraps encrypted data and provides controlled decryption through the .reveal API.
Security Model:
- Contains encrypted JSON data, never plaintext
- Requires explicit .reveal { } for decryption and plaintext access
- ALL serialization methods return '[CONCEALED]' to prevent leakage
- Maintains encryption context for proper AAD handling
- Thread-safe and supports concurrent access
Key Security Features:
- Universal Serialization Safety - ALL to_* methods protected
- Debugging Safety - inspect, logging, console output shows [CONCEALED]
- Exception Safety - never leaks plaintext in error messages
- Future-proof - any new serialization method automatically safe
- Memory Clearing - best-effort encrypted data clearing
Critical Design Principles:
- Secure by default - no auto-decryption anywhere
- Explicit decryption - .reveal required for plaintext access
- Comprehensive protection - covers ALL serialization paths
- Auditable access - easy to grep for .reveal usage
Example Usage: user = User.new user.secret_data = "sensitive info" # Encrypts and wraps user.secret_data # Returns ConcealedString user.secret_data.reveal { |plain| ... } # Explicit decryption user.to_h # Safe - contains [CONCEALED] user.to_json # Safe - contains [CONCEALED]
Constant Summary collapse
- REDACTED =
'[CONCEALED]'.freeze
Class Method Summary collapse
-
.finalizer_proc(encrypted_data) ⇒ Object
Finalizer to attempt memory cleanup.
Instance Method Summary collapse
-
#+(_other) ⇒ Object
String concatenation operations return concealed result.
-
#==(other) ⇒ Object
(also: #eql?)
Returns true when it's literally the same object, otherwise false.
-
#as_json ⇒ Object
Prevent exposure in Rails serialization (as_json -> to_json).
-
#belongs_to_context?(expected_record, expected_field_name) ⇒ Boolean
Validate that this ConcealedString belongs to the given record context.
- #blank? ⇒ Boolean
-
#clear! ⇒ Object
Clear the encrypted data from memory.
-
#cleared? ⇒ Boolean
Check if the encrypted data has been cleared.
-
#coerce(other) ⇒ Object
Handle coercion for concatenation like "string" + concealed.
- #concat(_other) ⇒ Object
-
#deconstruct ⇒ Object
Pattern matching safety (Ruby 3.0+).
- #deconstruct_keys ⇒ Object
- #downcase ⇒ Object
- #each {|'[CONCEALED]'| ... } ⇒ Object
- #empty? ⇒ Boolean
-
#encrypted_value ⇒ String?
Access the encrypted data for database storage.
- #gsub ⇒ Object
-
#hash ⇒ Object
Consistent hash to prevent timing attacks.
- #include?(_substring) ⇒ Boolean
-
#initialize(encrypted_data, record, field_type) ⇒ ConcealedString
constructor
Create a concealed string wrapper.
-
#inspect ⇒ Object
Safe representation for debugging and console output.
- #length ⇒ Object
-
#map {|'[CONCEALED]'| ... } ⇒ Object
Enumerable methods for safety.
- #present? ⇒ Boolean
-
#reveal {|String| ... } ⇒ Object
Primary API: reveal the decrypted plaintext in a controlled block.
- #size ⇒ Object
-
#strip ⇒ Object
String pattern matching methods.
- #to_a ⇒ Object
-
#to_h ⇒ Object
Hash/Array serialization safety.
-
#to_json ⇒ Object
Prevent exposure in JSON serialization - fail closed for security.
-
#to_s ⇒ Object
Prevent accidental exposure through string conversion and serialization.
-
#upcase ⇒ Object
String methods that should return safe concealed values.
Constructor Details
#initialize(encrypted_data, record, field_type) ⇒ ConcealedString
Create a concealed string wrapper
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 48 def initialize(encrypted_data, record, field_type) @encrypted_data = encrypted_data.freeze @record = record @field_type = field_type @cleared = false # Parse and validate the encrypted data structure if @encrypted_data begin @encrypted_data_obj = Familia::Encryption::EncryptedData.from_json(@encrypted_data) # Validate that the encrypted data is decryptable (algorithm supported, etc.) @encrypted_data_obj.validate_decryptable! rescue Familia::EncryptionError => e raise Familia::EncryptionError, e. rescue StandardError => e raise Familia::EncryptionError, "Invalid encrypted data: #{e.}" end end ObjectSpace.define_finalizer(self, self.class.finalizer_proc(@encrypted_data)) end |
Class Method Details
.finalizer_proc(encrypted_data) ⇒ Object
Finalizer to attempt memory cleanup
280 281 282 283 284 285 286 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 280 def self.finalizer_proc(encrypted_data) proc do # Best effort cleanup - Ruby doesn't guarantee memory security # Only clear if not frozen to avoid FrozenError encrypted_data.clear if encrypted_data.respond_to?(:clear) && !encrypted_data.frozen? end end |
Instance Method Details
#+(_other) ⇒ Object
String concatenation operations return concealed result
200 201 202 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 200 def +(_other) '[CONCEALED]' end |
#==(other) ⇒ Object Also known as: eql?
Returns true when it's literally the same object, otherwise false. This prevents timing attacks where an attacker could potentially infer information about the secret value through comparison timing
145 146 147 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 145 def ==(other) object_id.equal?(other.object_id) # same object end |
#as_json ⇒ Object
Prevent exposure in Rails serialization (as_json -> to_json)
275 276 277 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 275 def as_json(*) '[CONCEALED]' end |
#belongs_to_context?(expected_record, expected_field_name) ⇒ Boolean
Validate that this ConcealedString belongs to the given record context
This prevents cross-context attacks where encrypted data is moved between different records or field contexts. While moving ConcealedString objects manually is not a normal use case, this provides defense in depth.
107 108 109 110 111 112 113 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 107 def belongs_to_context?(expected_record, expected_field_name) return false if @record.nil? || @field_type.nil? @record.instance_of?(expected_record.class) && @record.identifier == expected_record.identifier && @field_type.instance_variable_get(:@name) == expected_field_name end |
#blank? ⇒ Boolean
195 196 197 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 195 def blank? false # Never blank if encrypted data exists end |
#clear! ⇒ Object
Clear the encrypted data from memory
Safe to call multiple times. This provides best-effort memory clearing within Ruby's limitations.
120 121 122 123 124 125 126 127 128 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 120 def clear! return if @cleared @encrypted_data = nil @record = nil @field_type = nil @cleared = true freeze end |
#cleared? ⇒ Boolean
Check if the encrypted data has been cleared
134 135 136 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 134 def cleared? @cleared end |
#coerce(other) ⇒ Object
Handle coercion for concatenation like "string" + concealed
209 210 211 212 213 214 215 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 209 def coerce(other) if other.is_a?(String) ['[CONCEALED]', '[CONCEALED]'] else [other, '[CONCEALED]'] end end |
#concat(_other) ⇒ Object
204 205 206 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 204 def concat(_other) '[CONCEALED]' end |
#deconstruct ⇒ Object
Pattern matching safety (Ruby 3.0+)
261 262 263 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 261 def deconstruct ['[CONCEALED]'] end |
#deconstruct_keys ⇒ Object
265 266 267 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 265 def deconstruct_keys(*) { concealed: true } end |
#downcase ⇒ Object
179 180 181 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 179 def downcase '[CONCEALED]' end |
#each {|'[CONCEALED]'| ... } ⇒ Object
236 237 238 239 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 236 def each yield '[CONCEALED]' if block_given? self end |
#empty? ⇒ Boolean
138 139 140 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 138 def empty? @encrypted_data.to_s.empty? end |
#encrypted_value ⇒ String?
Access the encrypted data for database storage
This method is used internally by the field type system for persisting the encrypted data to the database.
157 158 159 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 157 def encrypted_value @encrypted_data end |
#gsub ⇒ Object
222 223 224 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 222 def gsub(*) '[CONCEALED]' end |
#hash ⇒ Object
Consistent hash to prevent timing attacks
256 257 258 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 256 def hash ConcealedString.hash end |
#include?(_substring) ⇒ Boolean
226 227 228 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 226 def include?(_substring) false # Never reveal substring presence end |
#inspect ⇒ Object
Safe representation for debugging and console output
242 243 244 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 242 def inspect '[CONCEALED]' end |
#length ⇒ Object
183 184 185 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 183 def length 11 # Fixed concealed length to match '[CONCEALED]' length end |
#map {|'[CONCEALED]'| ... } ⇒ Object
Enumerable methods for safety
231 232 233 234 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 231 def map yield '[CONCEALED]' if block_given? ['[CONCEALED]'] end |
#present? ⇒ Boolean
191 192 193 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 191 def present? true # Always return true since encrypted data exists end |
#reveal {|String| ... } ⇒ Object
Primary API: reveal the decrypted plaintext in a controlled block
This is the ONLY way to access plaintext from encrypted fields. The plaintext is decrypted fresh each time using the current record state and AAD context.
Security Warning: Avoid operations inside the block that create uncontrolled copies of the plaintext (dup, interpolation, etc.)
Example: user.api_token.reveal do |token| HTTP.post('/api', headers: { 'X-Token' => token }) end
87 88 89 90 91 92 93 94 95 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 87 def reveal raise ArgumentError, 'Block required for reveal' unless block_given? raise SecurityError, 'Encrypted data already cleared' if cleared? raise SecurityError, 'No encrypted data to reveal' if @encrypted_data.nil? # Decrypt using current record context and AAD plaintext = @field_type.decrypt_value(@record, @encrypted_data) yield plaintext end |
#size ⇒ Object
187 188 189 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 187 def size length end |
#strip ⇒ Object
String pattern matching methods
218 219 220 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 218 def strip '[CONCEALED]' end |
#to_a ⇒ Object
251 252 253 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 251 def to_a ['[CONCEALED]'] end |
#to_h ⇒ Object
Hash/Array serialization safety
247 248 249 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 247 def to_h '[CONCEALED]' end |
#to_json ⇒ Object
Prevent exposure in JSON serialization - fail closed for security
270 271 272 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 270 def to_json(*) raise Familia::SerializerError, 'ConcealedString cannot be serialized to JSON' end |
#to_s ⇒ Object
Prevent accidental exposure through string conversion and serialization
Ruby has two string conversion methods with different purposes:
- to_s: explicit conversion (
obj.to_s, string interpolation"#{obj}") - to_str: implicit coercion (
File.read(obj),"prefix" + obj)
We implement to_s for safe logging/debugging but deliberately omit to_str to prevent encrypted data from being used where strings are expected.
170 171 172 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 170 def to_s '[CONCEALED]' end |
#upcase ⇒ Object
String methods that should return safe concealed values
175 176 177 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 175 def upcase '[CONCEALED]' end |