Class: Karafka::Pro::Encryption::Ciphers::Envelope

Inherits:
Base
  • Object
show all
Defined in:
lib/karafka/pro/encryption/ciphers/envelope.rb

Overview

Hybrid cipher where each payload is encrypted with a one-time AES-256-GCM key and only that key is RSA-wrapped (OAEP padding). Handles payloads of any size.

The GCM auth tag covers the whole envelope (header included), so corruption and truncation are detected reliably. Note this is corruption detection, not authenticity: the RSA public key is distributed to all producers, so any of its holders can construct a valid envelope.

Instance Method Summary collapse

Methods inherited from Base

#initialize, #warmup

Constructor Details

This class inherits a constructor from Karafka::Pro::Encryption::Ciphers::Base

Instance Method Details

#decrypt(version, content) ⇒ String

Note:

All failure paths stay within the OpenSSL::PKey error family: the explicit guards raise RSAError, while an OAEP unwrap failure (e.g. non-matching private key) surfaces from the EVP API as its parent PKeyError

Decrypts an envelope produced by #encrypt

Parameters:

  • version (String)

    encryption version

  • content (String)

    binary envelope

Returns:

  • (String)

    decrypted content



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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
155
156
157
158
159
# File 'lib/karafka/pro/encryption/ciphers/envelope.rb', line 113

def decrypt(version, content)
  content = content.b

  if content.bytesize < VERSION_BYTES
    raise(OpenSSL::PKey::RSAError, "corrupted or truncated envelope")
  end

  # Version goes next so future layouts of different sizes are reported as
  # unsupported to older consumers instead of as corrupted
  if content[0, VERSION_BYTES] != VERSION
    raise(OpenSSL::PKey::RSAError, "unsupported envelope version")
  end

  pem = private_pem(version)
  wrapped_size = pem.n.num_bytes
  header_size = VERSION_BYTES + wrapped_size + IV_BYTES
  min_size = header_size + TAG_BYTES

  if content.bytesize < min_size
    raise(OpenSSL::PKey::RSAError, "corrupted or truncated envelope")
  end

  wrapped_key = content[VERSION_BYTES, wrapped_size]
  iv = content[VERSION_BYTES + wrapped_size, IV_BYTES]
  tag = content[header_size, TAG_BYTES]
  ciphertext = content[min_size..]

  aes_key = pem.decrypt(wrapped_key, OAEP_OPTIONS)

  # OAEP unwrapping of a foreign envelope fails reliably, but anyone holding the
  # public key can wrap a string of arbitrary length. Without this guard such input
  # would surface as an ArgumentError from the AES key assignment, escaping the
  # OpenSSL error family this method otherwise normalizes to
  unless aes_key.bytesize == KEY_BYTES
    raise(OpenSSL::PKey::RSAError, "invalid envelope key size")
  end

  aes = OpenSSL::Cipher.new(AES).decrypt
  aes.key = aes_key
  aes.iv = iv
  aes.auth_tag = tag
  aes.auth_data = content[0, header_size]

  # Same empty-input guard as on the encryption side; `#final` still runs and thus
  # still verifies the auth tag for empty payloads
  ciphertext.empty? ? aes.final : aes.update(ciphertext) + aes.final
end

#encrypt(content) ⇒ String

Encrypts content with a one-time AES-256-GCM key and RSA-wraps that key using OAEP padding. Unlike PKCS1 v1.5, OAEP unwrapping with a non-matching key fails reliably instead of occasionally yielding garbage. The GCM tag additionally authenticates the whole envelope header, so any bit flip in the version byte, wrapped key or iv is detected, not only ciphertext corruption.

Parameters:

  • content (String)

    content to encrypt

Returns:

  • (String)

    binary envelope (see VERSION for the format)



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/karafka/pro/encryption/ciphers/envelope.rb', line 89

def encrypt(content)
  aes = OpenSSL::Cipher.new(AES).encrypt
  aes_key = aes.random_key
  iv = aes.random_iv

  wrapped_key = public_pem.encrypt(aes_key, OAEP_OPTIONS)

  header = VERSION + wrapped_key + iv
  aes.auth_data = header

  # `Cipher#update` rejects empty input on openssl gem < 3.1 (`data must not be
  # empty`), so empty payloads go straight to `#final`
  ciphertext = content.empty? ? aes.final : aes.update(content) + aes.final

  header + aes.auth_tag(TAG_BYTES) + ciphertext
end