Class: Familia::Encryption::Providers::AESGCMProvider

Inherits:
Familia::Encryption::Provider show all
Defined in:
lib/familia/encryption/providers/aes_gcm_provider.rb

Constant Summary collapse

ALGORITHM =
'aes-256-gcm'
NONCE_SIZE =
12
AUTH_TAG_SIZE =
16
KEY_SIZE =
32
LEGACY_HKDF_SALT =

The HKDF salt used before issue #310, when the salt was a static literal. Retained ONLY as a decryption fallback (see #hkdf_salts) so data written before the salt became application-specific stays readable after upgrading. Never used to encrypt new data.

'FamiliaEncryption'.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

This class inherits a constructor from Familia::Encryption::Provider

Class Method Details

.auth_tag_sizeObject



157
158
159
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 157

def self.auth_tag_size
  AUTH_TAG_SIZE
end

.available?Boolean

Returns:

  • (Boolean)


34
35
36
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 34

def self.available?
  true # OpenSSL is always available
end

.nonce_sizeObject



153
154
155
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 153

def self.nonce_size
  NONCE_SIZE
end

.priorityObject



38
39
40
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 38

def self.priority
  50 # Fallback option
end

Instance Method Details

#algorithmObject



169
170
171
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 169

def algorithm
  ALGORITHM
end

#auth_tag_sizeObject



165
166
167
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 165

def auth_tag_size
  AUTH_TAG_SIZE
end

#current_hkdf_saltObject

The HKDF salt used to ENCRYPT new data.

Unlike #hkdf_salts (the permissive decryption candidate list, which deliberately ends with the pre-#310 global static salt so old ciphertext stays readable), this fails CLOSED. A nil or empty encryption_hkdf_salt is a misconfiguration -- and the raw attr_writer can set one, bypassing the reader's guards -- so silently encrypting new data under the legacy global static salt would quietly withhold the #310 per-deployment domain separation. Refuse instead, so the operator notices (#311). To use the old global salt on purpose, set it explicitly as a non-empty string.

Raises:



114
115
116
117
118
119
120
121
122
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 114

def current_hkdf_salt
  salt = Familia.config.encryption_hkdf_salt
  return salt if salt.is_a?(String) && !salt.empty?

  raise EncryptionError,
        'encryption_hkdf_salt must be a non-empty string to encrypt; refusing to ' \
        'fall back to the legacy global HKDF salt. Set Familia.config.encryption_hkdf_salt ' \
        'for per-deployment domain separation.'
end

#decrypt(ciphertext, key, nonce, auth_tag, additional_data = nil) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 59

def decrypt(ciphertext, key, nonce, auth_tag, additional_data = nil)
  validate_key_length!(key)
  cipher = create_cipher(:decrypt)
  cipher.key = key
  cipher.iv = nonce
  cipher.auth_tag = auth_tag
  cipher.auth_data = additional_data.to_s if additional_data

  cipher.update(ciphertext) + cipher.final
rescue OpenSSL::Cipher::CipherError
  raise EncryptionError, 'Decryption failed - invalid key or corrupted data'
end

#derive_key(master_key, context, personal: nil, salt: nil) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 124

def derive_key(master_key, context, personal: nil, salt: nil)
  validate_key_length!(master_key)
  info = personal ? "#{context}:#{personal}" : context
  OpenSSL::KDF.hkdf(
    master_key,
    # Use application-specific material for the HKDF salt instead of a
    # static library literal. A fixed global salt is shared by every
    # deployment and weakens HKDF's extraction step / domain separation
    # (RFC 5869). The value comes from encryption_hkdf_salt -- a dedicated
    # AES-GCM knob, kept separate from the XChaCha20 personalization so
    # neither cipher family constrains the other (issue #311). See #310 (S2).
    #
    # `salt` defaults to the current encryption salt (#current_hkdf_salt),
    # which fails closed on a nil/blank config rather than silently using
    # the legacy global static salt. The decrypt path passes explicit
    # candidate salts from #hkdf_salts so existing ciphertext stays
    # decryptable after a salt change.
    salt: salt || current_hkdf_salt,
    info: info,
    length: KEY_SIZE,
    hash: 'SHA256'
  )
end

#encrypt(plaintext, key, additional_data = nil) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 42

def encrypt(plaintext, key, additional_data = nil)
  validate_key_length!(key)
  nonce = generate_nonce
  cipher = create_cipher(:encrypt)
  cipher.key = key
  cipher.iv = nonce
  cipher.auth_data = additional_data.to_s if additional_data

  ciphertext = cipher.update(plaintext.to_s) + cipher.final

  {
    ciphertext: ciphertext,
    auth_tag: cipher.auth_tag,
    nonce: nonce,
  }
end

#generate_nonceObject



72
73
74
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 72

def generate_nonce
  OpenSSL::Random.random_bytes(NONCE_SIZE)
end

#hkdf_saltsObject

Ordered list of HKDF salts to consider when DECRYPTING, current first.

Decryption walks this list until the authenticated decrypt succeeds, so it is intentionally permissive: it ends with the pre-#310 static salt and tolerates a blank current salt (dropped by #compact), so existing ciphertext stays readable even if the current config is broken. Each wrong salt yields a different key and fails GCM authentication cleanly, so trying them in turn never produces a false positive.

ENCRYPTION does NOT use this list's head -- see #current_hkdf_salt, which fails closed rather than silently encrypting under the legacy static salt.

The salt comes from a dedicated config knob (encryption_hkdf_salt), NOT the XChaCha20 personalization. HKDF accepts a salt of any length while BLAKE2b personalization is capped at 16 bytes, so the two cipher families keep separate inputs and never constrain each other (#311).



98
99
100
101
102
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 98

def hkdf_salts
  current = Familia.config.encryption_hkdf_salt
  history = Familia.config.encryption_hkdf_salt_history
  [current, *history, LEGACY_HKDF_SALT].compact.uniq
end

#nonce_sizeObject



161
162
163
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 161

def nonce_size
  NONCE_SIZE
end

#secure_wipe(key) ⇒ Object

Clear key from memory (no security guarantees in Ruby)



149
150
151
# File 'lib/familia/encryption/providers/aes_gcm_provider.rb', line 149

def secure_wipe(key)
  key&.clear
end