Class: ConcealedString

Inherits:
Object
  • Object
show all
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:

  1. Universal Serialization Safety - ALL to_* methods protected
  2. Debugging Safety - inspect, logging, console output shows [CONCEALED]
  3. Exception Safety - never leaks plaintext in error messages
  4. Future-proof - any new serialization method automatically safe
  5. 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

Instance Method Summary collapse

Constructor Details

#initialize(encrypted_data, record, field_type) ⇒ ConcealedString

Create a concealed string wrapper

Parameters:

  • encrypted_data (String)

    The encrypted JSON data

  • record (Familia::Horreum)

    The record instance for context

  • field_type (EncryptedFieldType)

    The field type for decryption



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.message
    rescue StandardError => e
      raise Familia::EncryptionError, "Invalid encrypted data: #{e.message}"
    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_jsonObject

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.

Parameters:

  • expected_record (Familia::Horreum)

    The record that should own this data

  • expected_field_name (Symbol)

    The field name that should own this data

Returns:

  • (Boolean)

    true if contexts match, false otherwise



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

Returns:

  • (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

Returns:

  • (Boolean)

    true if cleared, false otherwise



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

#deconstructObject

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_keysObject



265
266
267
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 265

def deconstruct_keys(*)
  { concealed: true }
end

#downcaseObject



179
180
181
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 179

def downcase
  '[CONCEALED]'
end

#each {|'[CONCEALED]'| ... } ⇒ Object

Yields:

  • ('[CONCEALED]')


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

Returns:

  • (Boolean)


138
139
140
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 138

def empty?
  @encrypted_data.to_s.empty?
end

#encrypted_valueString?

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.

Returns:

  • (String, nil)

    The encrypted JSON data



157
158
159
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 157

def encrypted_value
  @encrypted_data
end

#gsubObject



222
223
224
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 222

def gsub(*)
  '[CONCEALED]'
end

#hashObject

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

Returns:

  • (Boolean)


226
227
228
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 226

def include?(_substring)
  false # Never reveal substring presence
end

#inspectObject

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

#lengthObject



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

Yields:

  • ('[CONCEALED]')


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

Returns:

  • (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

Yields:

  • (String)

    The decrypted plaintext value

Returns:

  • (Object)

    The return value of the block

Raises:

  • (ArgumentError)


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

#sizeObject



187
188
189
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 187

def size
  length
end

#stripObject

String pattern matching methods



218
219
220
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 218

def strip
  '[CONCEALED]'
end

#to_aObject



251
252
253
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 251

def to_a
  ['[CONCEALED]']
end

#to_hObject

Hash/Array serialization safety



247
248
249
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 247

def to_h
  '[CONCEALED]'
end

#to_jsonObject

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_sObject

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

#upcaseObject

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