Module: Philiprehberger::Crypt

Defined in:
lib/philiprehberger/crypt.rb,
lib/philiprehberger/crypt/version.rb

Defined Under Namespace

Classes: DecryptionError, Error

Constant Summary collapse

CIPHER =
'aes-256-gcm'
IV_LENGTH =
12
AUTH_TAG_LENGTH =
16
KEY_LENGTH =
32
SALT_LENGTH =
32
PBKDF2_ITERATIONS =
100_000
HASH_ALGORITHMS =
{
  sha256: 'SHA256',
  sha384: 'SHA384',
  sha512: 'SHA512'
}.freeze
VERSION =
'0.4.0'

Class Method Summary collapse

Class Method Details

.decrypt(data, key:) ⇒ String

Decrypt data encrypted with encrypt.

Parameters:

  • data (String)

    Base64-encoded encrypted data from encrypt

  • key (String)

    the same key used for encryption

Returns:

  • (String)

    the decrypted plaintext

Raises:

  • (DecryptionError)

    if decryption fails

  • (ArgumentError)

    if key length is invalid



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/philiprehberger/crypt.rb', line 50

def self.decrypt(data, key:)
  raw_key = normalize_key(key)
  raw = Base64.strict_decode64(data)

  iv = raw[0, IV_LENGTH]
  auth_tag = raw[IV_LENGTH, AUTH_TAG_LENGTH]
  ciphertext = raw[(IV_LENGTH + AUTH_TAG_LENGTH)..]

  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.decrypt
  cipher.key = raw_key
  cipher.iv = iv
  cipher.auth_tag = auth_tag
  cipher.auth_data = ''

  ciphertext.empty? ? cipher.final : cipher.update(ciphertext) + cipher.final
rescue OpenSSL::Cipher::CipherError => e
  raise DecryptionError, "Decryption failed: #{e.message}"
end

.derive_key(password, salt:, iterations: PBKDF2_ITERATIONS) ⇒ String

Derive an encryption key from a password using PBKDF2-HMAC-SHA256.

Parameters:

  • password (String)

    the password to derive from

  • salt (String)

    a random salt (use random_salt to generate)

  • iterations (Integer) (defaults to: PBKDF2_ITERATIONS)

    number of PBKDF2 iterations (default: 100_000)

Returns:

Raises:

  • (ArgumentError)

    if iterations is less than 1



77
78
79
80
81
82
83
84
85
86
87
# File 'lib/philiprehberger/crypt.rb', line 77

def self.derive_key(password, salt:, iterations: PBKDF2_ITERATIONS)
  raise ArgumentError, 'iterations must be >= 1' if iterations.to_i < 1

  OpenSSL::PKCS5.pbkdf2_hmac(
    password.to_s,
    salt,
    iterations.to_i,
    KEY_LENGTH,
    OpenSSL::Digest.new('SHA256')
  )
end

.encrypt(data, key:) ⇒ String

Encrypt data using AES-256-GCM.

Parameters:

  • data (String)

    the plaintext data to encrypt

  • key (String)

    a 32-byte encryption key (raw bytes or hex-encoded)

Returns:

  • (String)

    Base64-encoded string containing IV + auth tag + ciphertext

Raises:

  • (ArgumentError)

    if key length is invalid



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/philiprehberger/crypt.rb', line 27

def self.encrypt(data, key:)
  raw_key = normalize_key(key)
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.encrypt
  cipher.key = raw_key

  iv = cipher.random_iv
  cipher.auth_data = ''

  plaintext = data.to_s
  ciphertext = plaintext.empty? ? cipher.final : cipher.update(plaintext) + cipher.final
  auth_tag = cipher.auth_tag(AUTH_TAG_LENGTH)

  Base64.strict_encode64(iv + auth_tag + ciphertext)
end

.envelope_decrypt(envelope, master_key:) ⇒ String

Decrypt data encrypted with envelope_encrypt.

Parameters:

  • envelope (Hash)

    with :encrypted_data and :encrypted_key keys

  • master_key (String)

    the master key used during envelope encryption

Returns:

  • (String)

    the decrypted plaintext

Raises:

  • (DecryptionError)

    if decryption fails

  • (ArgumentError)

    if master_key length is invalid



208
209
210
211
# File 'lib/philiprehberger/crypt.rb', line 208

def self.envelope_decrypt(envelope, master_key:)
  data_key = decrypt(envelope[:encrypted_key], key: master_key)
  decrypt(envelope[:encrypted_data], key: data_key)
end

.envelope_encrypt(data, master_key:) ⇒ Hash

Encrypt data using envelope encryption.

Generates a random data key, encrypts data with it, then encrypts the data key with the master key.

Parameters:

  • data (String)

    the plaintext data to encrypt

  • master_key (String)

    a 32-byte master key (raw bytes or hex-encoded)

Returns:

  • (Hash)

    with :encrypted_data and :encrypted_key (both Base64 strings)

Raises:

  • (ArgumentError)

    if master_key length is invalid



193
194
195
196
197
198
199
# File 'lib/philiprehberger/crypt.rb', line 193

def self.envelope_encrypt(data, master_key:)
  data_key = SecureRandom.random_bytes(KEY_LENGTH)
  encrypted_data = encrypt(data, key: data_key)
  encrypted_key = encrypt(data_key, key: master_key)

  { encrypted_data: encrypted_data, encrypted_key: encrypted_key }
end

.hash(data, algorithm: :sha256) ⇒ String

Compute a cryptographic hash of data.

Parameters:

  • data (String)

    the data to hash

  • algorithm (Symbol) (defaults to: :sha256)

    the hash algorithm (:sha256, :sha384, or :sha512)

Returns:

  • (String)

    the hex-encoded digest

Raises:

  • (ArgumentError)

    if algorithm is unsupported



149
150
151
152
153
154
# File 'lib/philiprehberger/crypt.rb', line 149

def self.hash(data, algorithm: :sha256)
  algo = HASH_ALGORITHMS[algorithm]
  raise ArgumentError, "Unsupported algorithm: #{algorithm}. Use :sha256, :sha384, or :sha512" unless algo

  OpenSSL::Digest.new(algo).hexdigest(data.to_s)
end

.hash_and_hmac(data, key:, algorithm: :sha256) ⇒ Hash

Compute both a cryptographic hash and HMAC signature of data in one call.

Delegates to hash and hmac using the same algorithm for both.

Parameters:

  • data (String)

    the data to hash and sign

  • key (String)

    the HMAC secret key

  • algorithm (Symbol) (defaults to: :sha256)

    hash algorithm (:sha256, :sha384, or :sha512)

Returns:

  • (Hash)

    with :hash (hex digest) and :hmac (hex HMAC signature)

Raises:

  • (ArgumentError)

    if algorithm is unsupported



165
166
167
168
169
170
# File 'lib/philiprehberger/crypt.rb', line 165

def self.hash_and_hmac(data, key:, algorithm: :sha256)
  {
    hash: hash(data, algorithm: algorithm),
    hmac: hmac(data, key: key, algorithm: algorithm)
  }
end

.hmac(data, key:, algorithm: :sha256) ⇒ String

Compute an HMAC signature for data using the given key.

Parameters:

  • data (String)

    the data to sign

  • key (String)

    the HMAC secret key

  • algorithm (Symbol) (defaults to: :sha256)

    hash algorithm (:sha256, :sha384, or :sha512)

Returns:

  • (String)

    hex-encoded HMAC digest

Raises:

  • (ArgumentError)

    if algorithm is unsupported



96
97
98
99
100
101
# File 'lib/philiprehberger/crypt.rb', line 96

def self.hmac(data, key:, algorithm: :sha256)
  algo = HASH_ALGORITHMS[algorithm]
  raise ArgumentError, "Unsupported algorithm: #{algorithm}. Use :sha256, :sha384, or :sha512" unless algo

  OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(algo), key.to_s, data.to_s)
end

.hmac_verify(data, signature:, key:, algorithm: :sha256) ⇒ Boolean

Verify an HMAC signature in constant time.

Parameters:

  • data (String)

    the original data

  • signature (String)

    the expected hex-encoded HMAC signature

  • key (String)

    the HMAC secret key

  • algorithm (Symbol) (defaults to: :sha256)

    hash algorithm used to sign

Returns:

  • (Boolean)

    true if the signature matches



110
111
112
113
# File 'lib/philiprehberger/crypt.rb', line 110

def self.hmac_verify(data, signature:, key:, algorithm: :sha256)
  expected = hmac(data, key: key, algorithm: algorithm)
  secure_compare(expected, signature.to_s)
end

.random_bytes(n) ⇒ String

Generate cryptographically secure random bytes.

Parameters:

  • n (Integer)

    the number of random bytes

Returns:

  • (String)

    a binary string of n random bytes



217
218
219
# File 'lib/philiprehberger/crypt.rb', line 217

def self.random_bytes(n)
  SecureRandom.random_bytes(n)
end

.random_hex(n = 32) ⇒ String

Generate a cryptographically secure random hex string.

Parameters:

  • n (Integer) (defaults to: 32)

    the number of random bytes (output will be 2*n hex characters)

Returns:

  • (String)

    a hex-encoded random string



133
134
135
# File 'lib/philiprehberger/crypt.rb', line 133

def self.random_hex(n = 32)
  SecureRandom.hex(n)
end

.random_saltString

Generate a cryptographically secure random salt.

Returns:

  • (String)

    a 32-byte random salt (raw bytes)



118
119
120
# File 'lib/philiprehberger/crypt.rb', line 118

def self.random_salt
  SecureRandom.random_bytes(SALT_LENGTH)
end

.random_tokenString

Generate a cryptographically secure random token.

Returns:

  • (String)

    a URL-safe Base64-encoded random token (32 bytes of entropy)



125
126
127
# File 'lib/philiprehberger/crypt.rb', line 125

def self.random_token
  SecureRandom.urlsafe_base64(32)
end

.rotate_key(encrypted, old_key:, new_key:) ⇒ String

Re-encrypt data with a new key.

Parameters:

  • encrypted (String)

    Base64-encoded encrypted data from encrypt

  • old_key (String)

    the key used for the original encryption

  • new_key (String)

    the new key to encrypt with

Returns:

  • (String)

    Base64-encoded string encrypted with new_key

Raises:

  • (DecryptionError)

    if decryption with old_key fails

  • (ArgumentError)

    if key length is invalid



180
181
182
183
# File 'lib/philiprehberger/crypt.rb', line 180

def self.rotate_key(encrypted, old_key:, new_key:)
  plaintext = decrypt(encrypted, key: old_key)
  encrypt(plaintext, key: new_key)
end

.secure_compare(a, b) ⇒ Boolean

Constant-time string comparison to prevent timing attacks.

Parameters:

  • a (String)

    first string

  • b (String)

    second string

Returns:

  • (Boolean)

    true if the strings are equal



226
227
228
229
230
# File 'lib/philiprehberger/crypt.rb', line 226

def self.secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  OpenSSL.fixed_length_secure_compare(a, b)
end