Class: Familia::Encryption::Manager

Inherits:
Object
  • Object
show all
Defined in:
lib/familia/encryption/manager.rb

Overview

High-level encryption manager - replaces monolithic Encryption module

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(algorithm: nil) ⇒ Manager

Returns a new instance of Manager.

Raises:



11
12
13
14
15
# File 'lib/familia/encryption/manager.rb', line 11

def initialize(algorithm: nil)
  Registry.setup! if Registry.providers.empty?
  @provider = algorithm ? Registry.get(algorithm) : Registry.default_provider
  raise EncryptionError, 'No encryption provider available' unless @provider
end

Instance Attribute Details

#providerObject (readonly)

Returns the value of attribute provider.



9
10
11
# File 'lib/familia/encryption/manager.rb', line 9

def provider
  @provider
end

Instance Method Details

#decrypt(encrypted_json_or_hash, context:, additional_data: nil) ⇒ Object



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/familia/encryption/manager.rb', line 39

def decrypt(encrypted_json_or_hash, context:, additional_data: nil)
  if encrypted_json_or_hash.nil? || (encrypted_json_or_hash.respond_to?(:empty?) && encrypted_json_or_hash.empty?)
    return nil
  end

  # Increment counter immediately to track all decryption attempts, even failed ones
  Familia::Encryption.derivation_count.increment

  begin
    # Delegate parsing and instantiation to EncryptedData.from_json
    # Wrap validation errors for security (don't expose internal structure details)
    begin
      data = Familia::Encryption::EncryptedData.from_json(encrypted_json_or_hash)
      raise EncryptionError, 'Failed to parse encrypted data' unless data
    rescue EncryptionError => e
      # Re-wrap validation errors with generic message for security
      raise EncryptionError, "Decryption failed: #{e.message}"
    end

    # Validate algorithm support
    provider = Registry.get(data.algorithm)

    # Safely decode and validate sizes
    nonce = decode_and_validate(data.nonce, provider.nonce_size, 'nonce')
    ciphertext = decode_and_validate_ciphertext(data.ciphertext)
    auth_tag = decode_and_validate(data.auth_tag, provider.auth_tag_size, 'auth_tag')

    # Try each candidate HKDF salt, current first, so ciphertext written
    # before a salt change still decrypts. Providers without salt rotation
    # expose a single nil "salt" and are attempted exactly once. A wrong
    # salt derives a different key and fails the authenticated decrypt
    # cleanly, so iterating never yields a false positive. See #310 (S2).
    salts = provider.respond_to?(:hkdf_salts) ? provider.hkdf_salts : [nil]
    key = nil
    plaintext = nil
    last_error = nil
    salts.each do |salt|
      key = derive_key_without_increment(context, version: data.key_version, provider: provider, salt: salt)
      begin
        plaintext = provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
        break
      rescue EncryptionError => e
        last_error = e
        plaintext = nil
      ensure
        Familia::Encryption.secure_wipe(key)
      end
    end
    raise(last_error || EncryptionError.new('Decryption failed - invalid key or corrupted data')) if plaintext.nil?

    plaintext.force_encoding(data.encoding || 'UTF-8')
  rescue EncryptionError
    raise
  rescue Familia::SerializerError => e
    raise EncryptionError, "Invalid JSON structure: #{e.message}"
  rescue StandardError => e
    raise EncryptionError, "Decryption failed: #{e.message}"
  end
ensure
  # Defensive backstop only. The salt-rotation loop's per-iteration `ensure`
  # (around provider.decrypt) is the primary wipe and clears `key` on every
  # path -- success, failure, and break -- so by here `key` is already wiped
  # and this re-clear is a harmless no-op. It is kept so any future change to
  # the loop structure still cannot leave a derived key unwiped (#311).
  Familia::Encryption.secure_wipe(key) if key
end

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



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/familia/encryption/manager.rb', line 17

def encrypt(plaintext, context:, additional_data: nil)
  plaintext = plaintext.to_s
  return nil if plaintext.empty?

  key = derive_key(context)

  result = @provider.encrypt(plaintext, key, additional_data)

  encrypted_data = Familia::Encryption::EncryptedData.new(
    algorithm: @provider.algorithm,
    nonce: Base64.strict_encode64(result[:nonce]),
    ciphertext: Base64.strict_encode64(result[:ciphertext]),
    auth_tag: Base64.strict_encode64(result[:auth_tag]),
    key_version: current_key_version,
    encoding: plaintext.encoding.name,
  ).to_h

  Familia::JsonSerializer.dump(encrypted_data)
ensure
  Familia::Encryption.secure_wipe(key) if key
end