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
- 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
-
#+(_other) ⇒ Object
String concatenation operations return concealed result.
-
#==(other) ⇒ Object
(also: #eql?)
Returns true when it's literally the same object, otherwise false.
-
#algorithm ⇒ String?
Algorithm identifier recorded in the encrypted envelope (e.g. 'aes-256-gcm', 'xchacha20poly1305').
-
#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
Release the encrypted data and make the wrapper unusable.
-
#cleared? ⇒ Boolean
Check if the encrypted data has been cleared.
-
#coerce(other) ⇒ Object
Handle coercion for concatenation like "string" + concealed.
- #concat(_other) ⇒ Object
-
#concealed? ⇒ Boolean
Check if this wrapper is currently concealing encrypted data.
-
#context_description ⇒ String
Human-readable description of the record/field context this value was encrypted for.
-
#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
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. rescue StandardError => e raise Familia::EncryptionError, "Invalid encrypted data: #{e.}" 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 |
#algorithm ⇒ String?
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.
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_json ⇒ Object
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.
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
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
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.
168 169 170 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 168 def concealed? !@cleared && !@encrypted_data.nil? end |
#context_description ⇒ String
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.
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 |
#deconstruct ⇒ Object
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_keys ⇒ Object
314 315 316 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 314 def deconstruct_keys(*) { concealed: true } end |
#downcase ⇒ Object
228 229 230 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 228 def downcase '[CONCEALED]' end |
#each {|'[CONCEALED]'| ... } ⇒ Object
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
187 188 189 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 187 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.
206 207 208 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 206 def encrypted_value @encrypted_data end |
#gsub ⇒ Object
271 272 273 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 271 def gsub(*) '[CONCEALED]' end |
#hash ⇒ Object
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
275 276 277 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 275 def include?(_substring) false # Never reveal substring presence end |
#inspect ⇒ Object
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 |
#length ⇒ Object
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
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
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
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 |
#size ⇒ Object
236 237 238 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 236 def size length end |
#strip ⇒ Object
String pattern matching methods
267 268 269 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 267 def strip '[CONCEALED]' end |
#to_a ⇒ Object
300 301 302 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 300 def to_a ['[CONCEALED]'] end |
#to_h ⇒ Object
Hash/Array serialization safety
296 297 298 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 296 def to_h '[CONCEALED]' end |
#to_json ⇒ Object
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_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.
219 220 221 |
# File 'lib/familia/features/encrypted_fields/concealed_string.rb', line 219 def to_s '[CONCEALED]' end |
#upcase ⇒ Object
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 |