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. Explicit Lifecycle - clear! drops references and blocks further reveals

What this class does NOT do: wipe memory. Ruby cannot guarantee string wiping (copies, GC compaction, immutable internals), and the encrypted buffer here is frozen besides. clear! releases references and marks the wrapper unusable; reclaiming the bytes is left to the GC. See the RedactedString header for the full list of Ruby memory caveats.

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

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



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 56

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
end

Instance Method Details

#+(_other) ⇒ Object

String concatenation operations return concealed result



249
250
251
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 249

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



194
195
196
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 194

def ==(other)
  object_id.equal?(other.object_id) # same object
end

#algorithmString?

Algorithm identifier recorded in the encrypted envelope (e.g. 'aes-256-gcm', 'xchacha20poly1305').

Read from the stored envelope, so it reflects the algorithm the value was actually written with -- including a per-field pin -- not the current default provider. Returns nil once the data has been cleared.

Returns:

  • (String, nil)


181
182
183
184
185
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 181

def algorithm
  return nil if @cleared

  @encrypted_data_obj&.algorithm
end

#as_jsonObject

Prevent exposure in Rails serialization (as_json -> to_json)



324
325
326
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 324

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



113
114
115
116
117
118
119
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 113

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)


244
245
246
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 244

def blank?
  false # Never blank if encrypted data exists
end

#clear!Object

Release the encrypted data and make the wrapper unusable

Safe to call multiple times. Drops the references to the encrypted data and its record/field context, so further reveals raise SecurityError and the buffers become eligible for GC. This does NOT wipe memory -- the frozen encrypted string persists until the GC reclaims it, and Ruby offers no way to guarantee otherwise (see the class header).



142
143
144
145
146
147
148
149
150
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 142

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



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

def cleared?
  @cleared
end

#coerce(other) ⇒ Object

Handle coercion for concatenation like "string" + concealed



258
259
260
261
262
263
264
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 258

def coerce(other)
  if other.is_a?(String)
    ['[CONCEALED]', '[CONCEALED]']
  else
    [other, '[CONCEALED]']
  end
end

#concat(_other) ⇒ Object



253
254
255
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 253

def concat(_other)
  '[CONCEALED]'
end

#concealed?Boolean

Check if this wrapper is currently concealing encrypted data.

True for a live encrypted value; false once #clear! has wiped it. Used by Horreum#encrypted_fields_status to tell an active encrypted field apart from a cleared one.

Returns:

  • (Boolean)


168
169
170
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 168

def concealed?
  !@cleared && !@encrypted_data.nil?
end

#context_descriptionString

Human-readable description of the record/field context this value was encrypted for. Used to make context-isolation errors actionable by showing the expected context alongside the accessing one.

Returns:

  • (String)

    "Class:field:identifier", or a marker if context was cleared



127
128
129
130
131
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 127

def context_description
  return '(context cleared)' if @record.nil? || @field_type.nil?

  "#{@record.class.name}:#{@field_type.name}:#{@record.identifier}"
end

#deconstructObject

Pattern matching safety (Ruby 3.0+)



310
311
312
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 310

def deconstruct
  ['[CONCEALED]']
end

#deconstruct_keysObject



314
315
316
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 314

def deconstruct_keys(*)
  { concealed: true }
end

#downcaseObject



228
229
230
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 228

def downcase
  '[CONCEALED]'
end

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

Yields:

  • ('[CONCEALED]')


285
286
287
288
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 285

def each
  yield '[CONCEALED]' if block_given?
  self
end

#empty?Boolean

Returns:

  • (Boolean)


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

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



206
207
208
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 206

def encrypted_value
  @encrypted_data
end

#gsubObject



271
272
273
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 271

def gsub(*)
  '[CONCEALED]'
end

#hashObject

Consistent hash to prevent timing attacks



305
306
307
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 305

def hash
  ConcealedString.hash
end

#include?(_substring) ⇒ Boolean

Returns:

  • (Boolean)


275
276
277
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 275

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

#inspectObject

Safe representation for debugging and console output



291
292
293
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 291

def inspect
  '[CONCEALED]'
end

#lengthObject



232
233
234
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 232

def length
  11 # Fixed concealed length to match '[CONCEALED]' length
end

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

Enumerable methods for safety

Yields:

  • ('[CONCEALED]')


280
281
282
283
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 280

def map
  yield '[CONCEALED]' if block_given?
  ['[CONCEALED]']
end

#present?Boolean

Returns:

  • (Boolean)


240
241
242
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 240

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)


93
94
95
96
97
98
99
100
101
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 93

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



236
237
238
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 236

def size
  length
end

#stripObject

String pattern matching methods



267
268
269
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 267

def strip
  '[CONCEALED]'
end

#to_aObject



300
301
302
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 300

def to_a
  ['[CONCEALED]']
end

#to_hObject

Hash/Array serialization safety



296
297
298
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 296

def to_h
  '[CONCEALED]'
end

#to_jsonObject

Prevent exposure in JSON serialization - fail closed for security



319
320
321
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 319

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.



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

def to_s
  '[CONCEALED]'
end

#upcaseObject

String methods that should return safe concealed values



224
225
226
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 224

def upcase
  '[CONCEALED]'
end