Class: RedactedString
- Inherits:
-
Object
- Object
- RedactedString
- Defined in:
- lib/familia/features/transient_fields/redacted_string.rb
Overview
RedactedString
A secure wrapper for sensitive string values (e.g., API keys, passwords, encryption keys). Designed to: - Prevent accidental logging/inspection - Enable secure memory wiping - Encourage safe usage patterns
⚠️ IMPORTANT: This is best-effort protection. Ruby does not guarantee memory zeroing. GC, string sharing, and internal optimizations may leave copies in memory.
⚠️ INPUT SECURITY: The constructor calls .dup on the input, creating a copy, but the original input value remains in memory uncontrolled. The caller is responsible for securely clearing the original.
Security Model:
- The secret is contained from the moment it’s wrapped.
- Access is available via .expose { }
for controlled use, or .value
for direct access.
- Manual .clear!
is required when done with the value (unlike SingleUseRedactedString).
- .to_s
and .inspect
return ‘[REDACTED]’ to prevent leaks in logs,
errors, or debugging.
Critical Gotchas:
- Ruby 3.4+ String Internals — Memory Safety Reality
- Ruby uses “compact strings” and copy-on-write semantics.
- Short strings (< 24 bytes on 64-bit) are embedded in the object (RSTRING_EMBED_LEN).
- Long strings use heap-allocated buffers, but may be shared or duplicated silently.
- There is no guarantee that GC will not copy the string before finalization.
-
Every .dup, .to_s, +, interpolation, or method call may create hidden copies: s = “secret” t = s.dup # New object, same content — now two copies u = s + “123” # New string — third copy “#t” # Interpolation — fourth copy These copies are not controlled by RedactedString and may persist.
- String Freezing & Immutability
.freeze
prevents mutation but does not prevent copying..replace
on a frozen string raises FrozenError — so wiping fails.
- RbNaCl::Util.zero Limitations
- Only works on mutable byte buffers.
- May not zero embedded strings if Ruby’s internal representation is immutable.
- Does not protect against memory dumps or GC-compacted heaps.
- Finalizers Are Not Guaranteed
- Ruby does not promise when (or if)
ObjectSpace.define_finalizer
runs. - Never rely on finalizers for security-critical wiping.
- Ruby does not promise when (or if)
Best Practices:
- Wrap secrets immediately on input (e.g., from ENV, params, DB).
- Clear original input after wrapping: secret.clear!
or secret = nil
- Use .expose { }
for short-lived operations — never store plaintext.
- Avoid passing RedactedString to logging, serialization, or debugging
tools.
- Prefer .expose { }
over any “getter” method.
- Do not subclass String — it leaks the underlying value in regex,
case, etc.
Example: password_input = params[:password] # Original value in memory password = RedactedString.new(password_input) password_input.clear! if password_input.respond_to?(:clear!) # or: params[:password] = nil # Clear reference (not guaranteed)
Direct Known Subclasses
Class Method Summary collapse
Instance Method Summary collapse
-
#==(other) ⇒ Object
(also: #eql?)
Returns true when it’s literally the same object, otherwise false.
-
#clear! ⇒ Object
Clear the internal buffer.
-
#cleared? ⇒ Boolean
-
#expose {|@value| ... } ⇒ Object
Primary API: expose the value in a block.
-
#hash ⇒ Object
All RedactedString instances have the same hash to prevent hash-based timing attacks or information leakage.
-
#initialize(original_value) ⇒ RedactedString
constructor
Wrap a sensitive value.
-
#inspect ⇒ Object
-
#to_s ⇒ Object
Always redact in logs, debugging, or string conversion.
-
#value ⇒ Object
Get the actual value (for convenience in less sensitive contexts) Returns the wrapped value or nil if cleared.
Constructor Details
#initialize(original_value) ⇒ RedactedString
Wrap a sensitive value. The input is not wiped — ensure it’s not reused.
79 80 81 82 83 84 85 86 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 79 def initialize(original_value) # WARNING: .dup only creates a shallow copy; the original may still exist # elsewhere in memory. @value = original_value.to_s.dup @cleared = false # Do NOT freeze — we need to mutate it in `#clear!` ObjectSpace.define_finalizer(self, self.class.finalizer_proc) end |
Class Method Details
.finalizer_proc ⇒ Object
158 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 158 def self.finalizer_proc = proc { |id| } |
Instance Method Details
#==(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
147 148 149 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 147 def ==(other) object_id.equal?(other.object_id) # same object end |
#clear! ⇒ Object
Clear the internal buffer. Safe to call multiple times.
REALITY CHECK: This doesn’t actually provide security in Ruby. - Ruby may have already copied the string elsewhere in memory - Garbage collection behavior is unpredictable - The original input value is still in memory somewhere - This is primarily for API consistency and preventing reuse
118 119 120 121 122 123 124 125 126 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 118 def clear! return if @value.nil? || @value.frozen? || @cleared # Simple clear - no security theater @value.clear if @value.respond_to?(:clear) @value = nil @cleared = true freeze # one and done end |
#cleared? ⇒ Boolean
142 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 142 def cleared? = @cleared |
#expose {|@value| ... } ⇒ Object
Primary API: expose the value in a block. The value remains accessible for multiple reads until manually cleared. Call clear! explicitly when done with the value.
⚠️ Security Warning: Avoid .dup, string interpolation, or other operations that create uncontrolled copies of the sensitive value.
Example: token.expose do |plain| # Good: use directly without copying HTTP.post(‘/api’, headers: { ‘X-Token’ => plain }) # Avoid: plain.dup, “prefix#plain”, plain[0..-1], etc. end # Value is still accessible after block token.clear! # Explicitly clear when done
104 105 106 107 108 109 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 104 def expose raise ArgumentError, 'Block required' unless block_given? raise SecurityError, 'Value already cleared' if cleared? yield @value end |
#hash ⇒ Object
All RedactedString instances have the same hash to prevent hash-based timing attacks or information leakage
154 155 156 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 154 def hash RedactedString.hash end |
#inspect ⇒ Object
141 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 141 def inspect = to_s |
#to_s ⇒ Object
Always redact in logs, debugging, or string conversion
140 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 140 def to_s = '[REDACTED]' |
#value ⇒ Object
Get the actual value (for convenience in less sensitive contexts) Returns the wrapped value or nil if cleared
⚠️ Security Warning: Direct access bypasses the controlled exposure pattern. Prefer .expose { } for better security practices.
133 134 135 136 137 |
# File 'lib/familia/features/transient_fields/redacted_string.rb', line 133 def value raise SecurityError, 'Value already cleared' if cleared? @value end |