class SecretKeys::Encryptor

Encyption helper for encrypting and decrypting values using AES-256-GCM and returning as Base64 encoded strings. The encrypted values also include a prefix that can be used to detect if a string is an encrypted value.

Constants

CIPHER
CipherParams

Basic struct to contain nonce, auth_tag, and data for passing around. Thought it was better than just passing an Array with positional params. @private

ENCODING_FORMAT

format: <nonce:12>, <auth_tag:16>, <data:*>

ENCRYPTED_PREFIX
HASH_FUNC
KDF_ITERATIONS
KEY_LENGTH
SALT_MATCHER

Valid salts are hexencoded strings

Public Class Methods

derive_key(password, salt:, length: KEY_LENGTH, iterations: KDF_ITERATIONS, hash: HASH_FUNC) click to toggle source

Derive a key of given length from a password and salt value.

# File lib/secret_keys/encryptor.rb, line 44
def derive_key(password, salt:, length: KEY_LENGTH, iterations: KDF_ITERATIONS, hash: HASH_FUNC)
  if defined?(OpenSSL::KDF)
    OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: length, hash: hash)
  else
    # Ruby 2.4 compatibility
    OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, length, hash)
  end
end
encrypted?(value) click to toggle source

Detect of the value is a string that was encrypted by this library.

# File lib/secret_keys/encryptor.rb, line 39
def encrypted?(value)
  value.is_a?(String) && value.start_with?(ENCRYPTED_PREFIX)
end
from_password(password, salt) click to toggle source

Create an Encryptor from a password and salt. This is a shortcut for generating an Encryptor with a 32 byte encryption key. The key will be derived from the password and salt. @param [String] password secret used to encrypt the data @param [String] salt random hex-encoded byte array for key derivation @return [SecretKeys::Encryptor] a new encryptor with key derived from password and salt

# File lib/secret_keys/encryptor.rb, line 28
def from_password(password, salt)
  raise ArgumentError, "Password must be present" if password.nil? || password.empty?
  raise ArgumentError, "Salt must be a hex encoded value" if salt.nil? || !SALT_MATCHER.match?(salt)
  # Convert the salt to raw byte string
  salt_bytes = [salt].pack("H*")
  derived_key = derive_key(password, salt: salt_bytes)

  new(derived_key)
end
new(raw_key) click to toggle source

@param [String] raw_key the key directly passed into the encrypt/decrypt functions. This must be exactly {KEY_LENGTH} bytes long.

# File lib/secret_keys/encryptor.rb, line 60
def initialize(raw_key)
  raise ArgumentError, "key must be #{KEY_LENGTH} bytes long" unless raw_key.bytesize == KEY_LENGTH
  @derived_key = raw_key
end
random_salt() click to toggle source

@return [String] hex encoded random bytes

# File lib/secret_keys/encryptor.rb, line 54
def random_salt
  SecureRandom.hex(16)
end

Public Instance Methods

decrypt(encrypted_str) click to toggle source

Decrypt a string with the encryption key. If the value is not a string or it was not encrypted with the encryption key, the value itself will be returned.

@param [String] encrypted_str Base64 encoded encrypted string with aes params (from {#encrypt}) @return [String] decrypted string value @raise [OpenSSL::Cipher::CipherError] there is something wrong with the encoded data (usually incorrect key)

# File lib/secret_keys/encryptor.rb, line 103
def decrypt(encrypted_str)
  return encrypted_str unless self.class.encrypted?(encrypted_str)

  decrypt_str = encrypted_str[ENCRYPTED_PREFIX.length..-1]
  params = decode_aes(decrypt_str)

  cipher = OpenSSL::Cipher.new(CIPHER).decrypt

  cipher.key = @derived_key
  cipher.iv = params.nonce
  cipher.auth_tag = params.auth_tag
  cipher.auth_data = ""

  decoded_str = cipher.update(params.data) + cipher.final

  # force to utf-8 encoding. We already ensured this when we encoded in the first place
  decoded_str.force_encoding(Encoding::UTF_8)
end
encrypt(str) click to toggle source

Encrypt a string with the encryption key. Encrypted values are also salted so calling this function multiple times will result in different values. Only strings can be encrypted. Any other object type will be return the value passed in.

@param [String] str string to encrypt (assumes UTF-8) @return [String] Base64 encoded encrypted string with all aes parameters

# File lib/secret_keys/encryptor.rb, line 71
def encrypt(str)
  return str unless str.is_a?(String)
  return "" if str == ""

  cipher = OpenSSL::Cipher.new(CIPHER).encrypt

  # Technically, this is a "bad" way to do things since we could theoretically
  # get a repeat nonce, compromising the algorithm. That said, it should be safe
  # from repeats as long as we don't use this key for more than 2^32 encryptions
  # so... rotate your keys/salt ever 4 billion encryption calls
  nonce = cipher.random_iv
  cipher.key = @derived_key
  cipher.auth_data = ""

  # Make sure the string is encoded as UTF-8. JSON/YAML only support string types
  # anyways, so if you passed in binary data, it was gonna fail anyways. This ensures
  # that we can easily decode the string later. If you have UTF-16 or something, deal with it.
  utf8_str = str.encode(Encoding::UTF_8)
  encrypted_data = cipher.update(utf8_str) + cipher.final
  auth_tag = cipher.auth_tag

  params = CipherParams.new(nonce, auth_tag, encrypted_data)

  encode_aes(params).prepend(ENCRYPTED_PREFIX)
end
encrypted?(value) click to toggle source
# File lib/secret_keys/encryptor.rb, line 122
def encrypted?(value)
  self.class.encrypted?(value)
end
inspect() click to toggle source
# File lib/secret_keys/encryptor.rb, line 126
def inspect
  obj_id = object_id.to_s(16).rjust(16, "0")
  "#<#{self.class.name}:0x#{obj_id}>"
end

Private Instance Methods

decode_aes(str) click to toggle source

Passed in an aes encoded string and returns a cipher object

# File lib/secret_keys/encryptor.rb, line 146
def decode_aes(str)
  unpacked_data = Base64.decode64(str).unpack(ENCODING_FORMAT)
  # Splat the data array apart
  # nonce, auth_tag, encrypted_data = unpacked_data
  CipherParams.new(*unpacked_data)
end
encode_aes(params) click to toggle source

Receive a cipher object (initialized with key) and data

# File lib/secret_keys/encryptor.rb, line 139
def encode_aes(params)
  encoded = params.values.pack(ENCODING_FORMAT)
  # encode base64 and get rid of trailing newline and unnecessary =
  Base64.encode64(encoded).chomp.tr("=", "")
end