3

What is the ruby (cypher / pbkdf2_hmac incantations of the openssl gem) equivalent to encrypting with a command like this:

echo 'Top secret text' | openssl enc -base64 -e -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2 -p

I'm trying to use the ruby openssl gem to do AES-256-CBC encryption of a plain text string. I found quite a few examples of how to do this, but then I want to be able to give my friend the unix openssl command necessary to decrypt it at the other end, which means I need to use the same settings and string concatenation approach that the unix command does.

I figured out a pair of unix commands to encrypt and then decrypt. I'm using PBKDF2 key derivation. I have wrapped these commands in ruby code doing system call-outs, so my encrypt_openssl_system_call is successfully the inverse of decrypt.

I want my open_ssl_gem_encrypt method to also be decryptable using decrypt... and at the moment it nearly works because I've cheated and passed in the @salt_used, @key_used, and @iv_used. Instead I will need to figure out the right way to call OpenSSL::KDF.pbkdf2_hmac (or similar?) to do key derivation.

But with matching those values I suppose I hoped to see it encrypt to the exact same cypher text, or at least come out with the right plain text. Weirdly it nearly works, producing the plain text but with 14 random characters at the start.

require 'openssl'
require 'base64'

def password
  "mypassword"
end

def encrypt_openssl_system_call(plain_text)
  command = "echo '#{plain_text}' | openssl enc -base64 -e -aes-256-cbc -salt -pass pass:'#{password}' -pbkdf2 -p"
  puts command
  output = `#{command}`
  puts output
  raise(output) unless $?.success?

  # Parse the actual used salt key and iv from this output
  rows = output.split("\n")
  @salt_used = [rows[0].split("salt=").last].pack('H*')
  @key_used = [rows[1].split("key=").last].pack('H*')
  @iv_used = [rows[2].split("iv =").last].pack('H*')
  encrypted = rows.last

  encrypted.rstrip!
  encrypted
end

def decrypt(encrypted)
  command = "echo '#{encrypted}' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'#{password}' -pbkdf2"
  puts command
  output = `#{command}`

  raise output unless $?.success?

  output.rstrip
rescue RuntimeError => e
  puts ">>> ERROR #{e.message}"
  puts e.backtrace

  e.message
end

def open_ssl_gem_encrypt(plain_text)
  # Key derivation (PBKDF2) not working yet
 # salt = 'Salted__' + OpenSSL::Random.random_bytes(8)
 # key = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: 10000, length: 32, hash: "sha1")

  salt = 'Salted__' + @salt_used
  key = @key_used

  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.encrypt
  cipher.key = key
  iv = @iv_used # cipher.random_iv
  cipher.iv  = iv

  # Concatenate bits in hopefully the right order?!
  encrypted = salt
  encrypted << iv
  encrypted << cipher.update(plain_text)
  encrypted << cipher.final

  Base64.encode64(encrypted).gsub(/\n/, '')
end

PLAIN_TEXT = "Top secret text"

puts "encrypt_openssl_system_call(#{PLAIN_TEXT.dump})"
encrypted = encrypt_openssl_system_call(PLAIN_TEXT)
puts "openssl command produced '#{encrypted}'"

puts "\n"
puts "decrypt('#{encrypted}')"
decrypted = decrypt(encrypted)
puts "openssl command decryption produced '#{decrypted}'"

puts "So far so good!" if decrypted==PLAIN_TEXT

puts "\n"
puts "open_ssl_gem_encrypt(#{PLAIN_TEXT.dump})"
encrypted_b = open_ssl_gem_encrypt(PLAIN_TEXT)
puts "OpenSSL gem produced '#{encrypted_b}'"

puts "\n"
puts "decrypt(#{encrypted_b.dump})"
decrypted_b = decrypt(encrypted_b)
puts "openssl command decryption produced '#{decrypted_b}'"

Output:

encrypt_openssl_system_call("Top secret text")
echo 'Top secret text' | openssl enc -base64 -e -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2 -p
salt=27BD7552C3308BBA
key=B99FCDC5F9296AD2B1488E49B8CD29EDF0D15E13C408B1EEB11A2050F6403E94
iv =1294745B0A06C42939283E51EAE29E5E
U2FsdGVkX18nvXVSwzCLuhZeGj9c+fKLG+nDaitgeRahxKftT20Rax2sYFpjiO3h
openssl command produced 'U2FsdGVkX18nvXVSwzCLuhZeGj9c+fKLG+nDaitgeRahxKftT20Rax2sYFpjiO3h'

decrypt('U2FsdGVkX18nvXVSwzCLuhZeGj9c+fKLG+nDaitgeRahxKftT20Rax2sYFpjiO3h')
echo 'U2FsdGVkX18nvXVSwzCLuhZeGj9c+fKLG+nDaitgeRahxKftT20Rax2sYFpjiO3h' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced 'Top secret text'
So far so good!

open_ssl_gem_encrypt("Top secret text")
OpenSSL gem produced 'U2FsdGVkX18nvXVSwzCLuhKUdFsKBsQpOSg+Uerinl4DIK8yljJ2aHCR+8m9Yrq3'

decrypt("U2FsdGVkX18nvXVSwzCLuhKUdFsKBsQpOSg+Uerinl4DIK8yljJ2aHCR+8m9Yrq3")
echo 'U2FsdGVkX18nvXVSwzCLuhKUdFsKBsQpOSg+Uerinl4DIK8yljJ2aHCR+8m9Yrq3' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced ' |�S��p��C��H�PTop secret text'

Partial success shows that I must surely be putting the string together in the right order at least!?

openssl gem version is 2.1.2

openssl version is "LibreSSL 3.3.6"

Harry Wood
  • 2,220
  • 2
  • 24
  • 46

2 Answers2

1

OpenSSL, when using a password/salt, stores the result as a concatenation of the ASCII encoding of Salted__, followed by the 8 bytes salt and the actual ciphertext (the -base64 option additionally performs a Base64 encoding).

In the current code, open_ssl_gem_encrypt() additionally writes the IV after the salt, which is incorrect. To fix this, the line encrypted << iv has to be removed. Then the output is:

encrypt_openssl_system_call("Top secret text")
echo 'Top secret text' | openssl enc -base64 -e -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2 -p
salt=6C70B083F3E5820D
key=091EB1C0043A17F1CB7932023BEBED42E36A8EB05709A93B8A35CBD02CAAEFEF
iv =C7D740EF0B0AB71E45C21A95C4004ADC
U2FsdGVkX19scLCD8+WCDRSr0MrSj82D5EOvO4nmG0jkd4KVyApoO2mIB1Tn7B8v
openssl command produced 'U2FsdGVkX19scLCD8+WCDRSr0MrSj82D5EOvO4nmG0jkd4KVyApoO2mIB1Tn7B8v'

decrypt('U2FsdGVkX19scLCD8+WCDRSr0MrSj82D5EOvO4nmG0jkd4KVyApoO2mIB1Tn7B8v')
echo 'U2FsdGVkX19scLCD8+WCDRSr0MrSj82D5EOvO4nmG0jkd4KVyApoO2mIB1Tn7B8v' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced 'Top secret text'
So far so good!

open_ssl_gem_encrypt("Top secret text")
OpenSSL gem produced 'U2FsdGVkX19scLCD8+WCDUflsXl8c0g44eFNBNy3S5Q='

decrypt("U2FsdGVkX19scLCD8+WCDUflsXl8c0g44eFNBNy3S5Q=")
echo 'U2FsdGVkX19scLCD8+WCDUflsXl8c0g44eFNBNy3S5Q=' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced 'Top secret text'

As can be seen, the decryption of the ciphertext generated with open_ssl_gem_encrypt() now works!


However, the ciphertexts generated with encrypt_openssl_system_call() and open_ssl_gem_encrypt() are different, which should not be, since your code applies the same salt in both cases.
The reason is that in encrypt_openssl_system_call() a line break is produced, which can be prevented by using -n (s. here). If -n is used, the output is:

encrypt_openssl_system_call("Top secret text")
echo -n 'Top secret text' | openssl enc -base64 -e -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2 -p
salt=F7083C17A99C44C2
key=E424DA2D1AD290B3FA89829495C3F04898150E1C722B2B86159CE10610553BB7
iv =F0ACA075C63C8D8D80F66137645F8333
U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw=
openssl command produced 'U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw='

decrypt('U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw=')
echo 'U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw=' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced 'Top secret text'
So far so good!

open_ssl_gem_encrypt("Top secret text")
OpenSSL gem produced 'U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw='

decrypt("U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw=")
echo 'U2FsdGVkX1/3CDwXqZxEwuInAQktjTeuW7TRmwETBgw=' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced 'Top secret text'

Now, with identical salt, the two ciphertexts produced are the same!


The common implementation for the encryption using a salt/password is to first generate a random 8 bytes salt (as may have been attempted in the commented-out part), and from that, together with password and key derivation function, the key and IV are calculated.
With the key and IV derived this way, the encryption is then performed. A possible implementation is:

def open_ssl_gem_encrypt_v2(plain_text)

  # Key derivation (PBKDF2) 
  salt = OpenSSL::Random.random_bytes(8)
  keyIv = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: 10000, length: 32+16, hash: "sha256")
  key = keyIv[0..31]
  iv = keyIv[32..-1]

  # Encrypt
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
  cipher.encrypt
  cipher.key = key
  cipher.iv  = iv
  ciphertext = cipher.update(plain_text) + cipher.final

  # Concatenate bits
  encrypted = 'Salted__' + salt + ciphertext

  Base64.encode64(encrypted).gsub(/\n/, '')
end

If this implementation is used instead of the old one, the last part of the output is e.g.:

...
So far so good!

open_ssl_gem_encrypt("Top secret text")
OpenSSL gem produced 'U2FsdGVkX18KvUdJOd/Pz0Sf0FmNMA2HzQWeXkR63Y8='

decrypt("U2FsdGVkX18KvUdJOd/Pz0Sf0FmNMA2HzQWeXkR63Y8=")
echo 'U2FsdGVkX18KvUdJOd/Pz0Sf0FmNMA2HzQWeXkR63Y8=' | openssl enc -base64 -d -aes-256-cbc -salt -pass pass:'mypassword' -pbkdf2
openssl command decryption produced 'Top secret text'

As can be seen, the ciphertext is successfully decrypted using OpenSSL.


Note that OpenSSL defaults to MD5 as digest for the key derivation function in earlier versions and SHA256 as of version v1.1.0.
For decryption to be successful, the digest used in the key derivation function KDF.pbkdf2_hmac() must match the digest of the OpenSSL version used for decryption.
The digest can be explicitly set in the OpenSSL statement with the -md option so that you are not limited to the default digest, s. openssl enc.

Topaco
  • 40,594
  • 4
  • 35
  • 62
  • Thanks so much. That was incredibly useful! I didn't get the `-n` bit of your solution working actually (I don't know why. That looked like it would be the easy bit!) but your rewrite `open_ssl_gem_encrypt_v2` works perfectly! – Harry Wood Mar 19 '23 at 23:25
-1

You can use my gem evp_bytes_to_key. From the README:

This gem is a pure Ruby implementation of OpenSSL's EVP_BytesToKey() function as it is used by the openssl command line utility. This function is used to generate a key and IV from a given password. (and optional salt)

The purpose of this gem is to make it easier to encrypt or decrypt data that has been encrypted by openssl with a password on the command line by replicating the logic used to derive a key and IV from a given password.

Under the examples section I have a nearly duplicate example of your initial request.

aes256 with a salt and IV

Encrypt with openssl:

echo -n "foo" | openssl enc -e -base64 -aes256 -S 73616c7473616c74 -pass pass:password

Note that the salt value is saltsalt which openssl requires to be in hexadecimal format 73616c7473616c74.

This returns:

U2FsdGVkX19zYWx0c2FsdOnid6UWvFAXeeXIe+sL0l8=

Decrypt with Ruby:

require 'openssl'
require 'base64'
require 'evp_bytes_to_key'

key = EvpBytesToKey::Key.new('password', 'saltsalt', 256, 16)
decipher = OpenSSL::Cipher.new('aes256')
decipher.decrypt
decipher.key = key.key
decipher.iv = key.iv
ciphertext = Base64.strict_decode64('U2FsdGVkX19zYWx0c2FsdOnid6UWvFAXeeXIe+sL0l8=')
ciphertext = ciphertext.byteslice(16..-1) if ciphertext.byteslice(0, 8) == 'Salted__'
plaintext = decipher.update(ciphertext) + decipher.final

This returns:

"foo"

Encrypt with Ruby:

require 'openssl'
require 'base64'
require 'evp_bytes_to_key'

salt = 'saltsalt'
key = EvpBytesToKey::Key.new('password', salt, 256, 16)
cipher = OpenSSL::Cipher.new('aes256')
cipher.encrypt
cipher.key = key.key
cipher.iv = key.iv
ciphertext = cipher.update('foo') + cipher.final
ciphertext = "Salted__#{salt}#{ciphertext}" if salt
ciphertext = Base64.strict_encode64(ciphertext)

This returns:

"U2FsdGVkX19zYWx0c2FsdOnid6UWvFAXeeXIe+sL0l8="

Decrypt with openssl:

echo -n "U2FsdGVkX19zYWx0c2FsdOnid6UWvFAXeeXIe+sL0l8=" | base64 --decode | openssl enc -d -aes256 -S 73616c7473616c74 -pass pass:password

This returns:

foo
anothermh
  • 9,815
  • 3
  • 33
  • 52
  • The OP code does not use the default `EVP_BytesToKey()` key derivation function, but PBKDF2. This is because the *-pbkdf2* option is explicitly set in the OpenSSL statements. Since `EVP_BytesToKey()` is deprecated and insecure compared to the more reliable PBKDF2 (the OpenSSL CLI itself issues a corresponding warning, at least for non-outdated versions, see also the [docs, Notes section](https://www.openssl.org/docs/man3.0/man3/EVP_BytesToKey.html)), switching to `EVP_BytesToKey()` would be a step backwards from a security point of view. – Topaco Mar 03 '23 at 07:49
  • All of that is clearly stated in the README. – anothermh Mar 03 '23 at 17:58