Class: SecretKeys::Encryptor

Inherits:
Object
  • Object
show all
Defined in:
lib/secret_keys/encryptor.rb

Overview

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.

Defined Under Namespace

Classes: CipherParams

Constant Summary collapse

ENCODING_FORMAT =

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

"a12 a16 a*"
ENCRYPTED_PREFIX =
"$AES$:"
CIPHER =
"aes-256-gcm"
KDF_ITERATIONS =
20_000
HASH_FUNC =
"sha256"
KEY_LENGTH =
32
SALT_MATCHER =

Valid salts are hexencoded strings

/\A(\h\h)+\z/.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(raw_key) ⇒ Encryptor

Returns a new instance of Encryptor.

Parameters:

  • raw_key (String)

    the key directly passed into the encrypt/decrypt functions. This must be exactly KEY_LENGTH bytes long.

Raises:

  • (ArgumentError)


59
60
61
62
# File 'lib/secret_keys/encryptor.rb', line 59

def initialize(raw_key)
  raise ArgumentError, "key must be #{KEY_LENGTH} bytes long" unless raw_key.bytesize == KEY_LENGTH
  @derived_key = raw_key
end

Class Method Details

.derive_key(password, salt:, length: KEY_LENGTH, iterations: KDF_ITERATIONS, hash: HASH_FUNC) ⇒ Object

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



43
44
45
46
47
48
49
50
# File 'lib/secret_keys/encryptor.rb', line 43

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) ⇒ Boolean

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

Returns:

  • (Boolean)


38
39
40
# File 'lib/secret_keys/encryptor.rb', line 38

def encrypted?(value)
  value.is_a?(String) && value.start_with?(ENCRYPTED_PREFIX)
end

.from_password(password, salt) ⇒ SecretKeys::Encryptor

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.

Parameters:

  • password (String)

    secret used to encrypt the data

  • salt (String)

    random hex-encoded byte array for key derivation

Returns:

Raises:

  • (ArgumentError)


27
28
29
30
31
32
33
34
35
# File 'lib/secret_keys/encryptor.rb', line 27

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

.random_saltString

Returns hex encoded random bytes.

Returns:

  • (String)

    hex encoded random bytes



53
54
55
# File 'lib/secret_keys/encryptor.rb', line 53

def random_salt
  SecureRandom.hex(16)
end

Instance Method Details

#decrypt(encrypted_str) ⇒ String

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.

Parameters:

  • encrypted_str (String)

    Base64 encoded encrypted string with aes params (from #encrypt)

Returns:

  • (String)

    decrypted string value

Raises:

  • (OpenSSL::Cipher::CipherError)

    there is something wrong with the encoded data (usually incorrect key)



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/secret_keys/encryptor.rb', line 102

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) ⇒ String

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.

Parameters:

  • str (String)

    string to encrypt (assumes UTF-8)

Returns:

  • (String)

    Base64 encoded encrypted string with all aes parameters



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
# File 'lib/secret_keys/encryptor.rb', line 70

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) ⇒ Boolean

Returns:

  • (Boolean)


121
122
123
# File 'lib/secret_keys/encryptor.rb', line 121

def encrypted?(value)
  self.class.encrypted?(value)
end

#inspectObject



125
126
127
128
# File 'lib/secret_keys/encryptor.rb', line 125

def inspect
  obj_id = object_id.to_s(16).rjust(16, "0")
  "#<#{self.class.name}:0x#{obj_id}>"
end