Module: Familia::Encryption

Defined in:
lib/familia/encryption.rb,
lib/familia/encryption/manager.rb,
lib/familia/encryption/provider.rb,
lib/familia/encryption/registry.rb,
lib/familia/encryption/request_cache.rb,
lib/familia/encryption/encrypted_data.rb,
lib/familia/encryption/providers/aes_gcm_provider.rb,
lib/familia/encryption/providers/xchacha20_poly1305_provider.rb,
lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb

Defined Under Namespace

Modules: Providers Classes: Manager, Provider, Registry

Constant Summary collapse

EncryptedData =
Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version) do
  # Class methods for parsing and validation
  def self.valid?(json_string)
    return true if json_string.nil? # Allow nil values
    return false unless json_string.is_a?(::String)

    begin
      parsed = Familia::JsonSerializer.parse(json_string, symbolize_names: true)
      return false unless parsed.is_a?(Hash)

      # Check for required fields
      required_fields = %i[algorithm nonce ciphertext auth_tag key_version]
      result = required_fields.all? { |field| parsed.key?(field) }
      Familia.debug "[valid?] result: #{result}, parsed: #{parsed}, required: #{required_fields}"
      result
    rescue Familia::SerializerError => e
      Familia.debug "[valid?] JSON error: #{e.message}"
      false
    end
  end

  def self.validate!(json_string)
    return nil if json_string.nil?

    raise EncryptionError, "Expected JSON string, got #{json_string.class}" unless json_string.is_a?(::String)

    begin
      parsed = Familia::JsonSerializer.parse(json_string, symbolize_names: true)
    rescue Familia::SerializerError => e
      raise EncryptionError, "Invalid JSON structure: #{e.message}"
    end

    raise EncryptionError, "Expected JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)

    required_fields = %i[algorithm nonce ciphertext auth_tag key_version]
    missing_fields = required_fields.reject { |field| parsed.key?(field) }

    raise EncryptionError, "Missing required fields: #{missing_fields.join(', ')}" unless missing_fields.empty?

    new(**parsed)
  end

  def self.from_json(json_string_or_hash)
    # Support both JSON strings (legacy) and already-parsed Hashes (v2.0 deserialization)
    if json_string_or_hash.is_a?(Hash)
      # Already parsed - use directly
      parsed = json_string_or_hash
      # Symbolize keys if they're strings
      parsed = parsed.transform_keys(&:to_sym) if parsed.keys.first.is_a?(String)
      new(**parsed)
    else
      # JSON string - validate and parse
      validate!(json_string_or_hash)
    end
  end

  # Instance methods for decryptability validation
  def decryptable?
    return false unless algorithm && nonce && ciphertext && auth_tag && key_version

    # Ensure Registry is set up before checking algorithms
    Registry.setup! if Registry.providers.empty?

    # Check if algorithm is supported
    return false unless Registry.providers.key?(algorithm)

    # Validate Base64 encoding of binary fields
    begin
      Base64.strict_decode64(nonce)
      Base64.strict_decode64(ciphertext)
      Base64.strict_decode64(auth_tag)
    rescue ArgumentError
      return false
    end

    true
  end

  def validate_decryptable!
    raise EncryptionError, 'Missing algorithm field' unless algorithm

    # Ensure Registry is set up before checking algorithms
    Registry.setup! if Registry.providers.empty?

    raise EncryptionError, "Unsupported algorithm: #{algorithm}" unless Registry.providers.key?(algorithm)

    unless nonce && ciphertext && auth_tag && key_version
      missing = []
      missing << 'nonce' unless nonce
      missing << 'ciphertext' unless ciphertext
      missing << 'auth_tag' unless auth_tag
      missing << 'key_version' unless key_version
      raise EncryptionError, "Missing required fields: #{missing.join(', ')}"
    end

    # Get the provider for size validation
    provider = Registry.providers[algorithm]

    # Validate Base64 encoding and sizes
    begin
      decoded_nonce = Base64.strict_decode64(nonce)
      if decoded_nonce.bytesize != provider.nonce_size
        raise EncryptionError, "Invalid nonce size: expected #{provider.nonce_size}, got #{decoded_nonce.bytesize}"
      end
    rescue ArgumentError
      raise EncryptionError, 'Invalid Base64 encoding in nonce field'
    end

    begin
      Base64.strict_decode64(ciphertext) # ciphertext can be variable size
    rescue ArgumentError
      raise EncryptionError, 'Invalid Base64 encoding in ciphertext field'
    end

    begin
      decoded_auth_tag = Base64.strict_decode64(auth_tag)
      if decoded_auth_tag.bytesize != provider.auth_tag_size
        raise EncryptionError,
              "Invalid auth_tag size: expected #{provider.auth_tag_size}, got #{decoded_auth_tag.bytesize}"
      end
    rescue ArgumentError
      raise EncryptionError, 'Invalid Base64 encoding in auth_tag field'
    end

    # Validate that the key version exists
    unless Familia.config.encryption_keys&.key?(key_version.to_sym)
      raise EncryptionError, "No key for version: #{key_version}"
    end

    self
  end
end

Class Method Summary collapse

Class Method Details

.benchmark(iterations: 1000) ⇒ Object

Benchmark available providers



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/familia/encryption.rb', line 129

def benchmark(iterations: 1000)
  require 'benchmark'
  test_data = 'x' * 1024 # 1KB test
  context = 'benchmark:test'

  results = {}
  Registry.providers.each do |algo, provider_class|
    next unless provider_class.available?

    mgr = Manager.new(algorithm: algo)
    time = Benchmark.realtime do
      iterations.times do
        encrypted = mgr.encrypt(test_data, context: context)
        mgr.decrypt(encrypted, context: context)
      end
    end

    results[algo] = {
      time: time,
      ops_per_sec: (iterations * 2 / time).round,
      priority: provider_class.priority,
    }
  end

  results
end

.clear_request_cache!Object

Clear all cached keys and disable caching



31
32
33
34
35
36
37
38
# File 'lib/familia/encryption/request_cache.rb', line 31

def clear_request_cache!
  if (cache = Fiber[:familia_request_cache])
    cache.each_value { |key| secure_wipe(key) }
    cache.clear
  end
  Fiber[:familia_request_cache_enabled] = false
  Fiber[:familia_request_cache] = nil
end

.decrypt(encrypted_json, context:, additional_data: nil) ⇒ Object

Quick decryption (auto-detects algorithm from data)



81
82
83
# File 'lib/familia/encryption.rb', line 81

def decrypt(encrypted_json, context:, additional_data: nil)
  manager.decrypt(encrypted_json, context: context, additional_data: additional_data)
end

.derivation_countObject

Derivation counter for monitoring no-caching behavior



95
96
97
# File 'lib/familia/encryption.rb', line 95

def derivation_count
  @derivation_count ||= Concurrent::AtomicFixnum.new(0)
end

.encrypt(plaintext, context:, additional_data: nil) ⇒ Object

Quick encryption with auto-selected best provider



76
77
78
# File 'lib/familia/encryption.rb', line 76

def encrypt(plaintext, context:, additional_data: nil)
  manager.encrypt(plaintext, context: context, additional_data: additional_data)
end

.encrypt_with(algorithm, plaintext, context:, additional_data: nil) ⇒ Object

Encrypt with specific algorithm



86
87
88
89
90
91
92
# File 'lib/familia/encryption.rb', line 86

def encrypt_with(algorithm, plaintext, context:, additional_data: nil)
  manager(algorithm: algorithm).encrypt(
    plaintext,
    context: context,
    additional_data: additional_data
  )
end

.hardware_acceleration?Boolean

Check if we're using hardware acceleration

Returns:

  • (Boolean)


123
124
125
126
# File 'lib/familia/encryption.rb', line 123

def hardware_acceleration?
  provider = Registry.default_provider
  provider && provider.class.name.include?('Hardware')
end

.manager(algorithm: nil) ⇒ Object

Get or create a manager with specific algorithm

Thread-safe lazy initialization using Concurrent::Map to ensure only a single Manager instance is created per algorithm even under concurrent encryption/decryption requests.



70
71
72
73
# File 'lib/familia/encryption.rb', line 70

def manager(algorithm: nil)
  @managers ||= Concurrent::Map.new
  @managers.fetch_or_store(algorithm) { Manager.new(algorithm: algorithm) }
end

.reset_derivation_count!Object



99
100
101
# File 'lib/familia/encryption.rb', line 99

def reset_derivation_count!
  derivation_count.value = 0
end

.secure_wipe(key) ⇒ Object

Clear key from memory (no security guarantees in Ruby)



104
105
106
# File 'lib/familia/encryption.rb', line 104

def secure_wipe(key)
  key&.clear
end

.statusObject

Get info about current encryption setup



109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/familia/encryption.rb', line 109

def status
  Registry.setup! if Registry.providers.empty?

  {
    default_algorithm: Registry.default_provider&.algorithm,
    available_algorithms: Registry.available_algorithms,
    preferred_available: Registry.default_provider&.class&.name,
    using_hardware: hardware_acceleration?,
    key_versions: encryption_keys.keys,
    current_version: current_key_version,
  }
end

.validate_configuration!Object

Raises:



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/familia/encryption.rb', line 156

def validate_configuration!
  raise EncryptionError, 'No encryption keys configured' if encryption_keys.empty?
  raise EncryptionError, 'No current key version set' unless current_key_version

  current_key = encryption_keys[current_key_version]
  raise EncryptionError, "Current key version not found: #{current_key_version}" unless current_key

  begin
    Base64.strict_decode64(current_key)
  rescue ArgumentError
    raise EncryptionError, 'Current encryption key is not valid Base64'
  end

  Registry.setup!
  raise EncryptionError, 'No encryption providers available' unless Registry.default_provider
end

.with_request_cacheObject

Enable request-scoped caching (opt-in for performance)



22
23
24
25
26
27
28
# File 'lib/familia/encryption/request_cache.rb', line 22

def with_request_cache
  Fiber[:familia_request_cache_enabled] = true
  Fiber[:familia_request_cache] = {}
  yield
ensure
  clear_request_cache!
end