diff --git a/.travis.yml b/.travis.yml index 1a80873..0c4e36d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,8 +7,8 @@ jdk: - openjdk11 scala: - - 2.13.0 - - 2.12.9 + - 2.13.1 + - 2.12.11 script: sbt ++$TRAVIS_SCALA_VERSION clean coverage test @@ -29,4 +29,4 @@ cache: before_cache: - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print -delete - - find $HOME/.sbt -name "*.lock" -print -delete \ No newline at end of file + - find $HOME/.sbt -name "*.lock" -print -delete diff --git a/README.md b/README.md index 273044d..d625e8e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Add the following resolver Add the library to your dependencies list - libraryDependencies += "com.evolutiongaming" %% "crypto" % "1.2-SNAPSHOT" + libraryDependencies += "com.evolutiongaming" %% "crypto" % "1.6" Create an application config file `environments/default.conf`: @@ -25,7 +25,7 @@ application { secret = "abcdefghijklmnop" // only for example purposes, you should use a strong randomly generated secret } -password = "2-DpBV9t/8a19P5o0fohf//Lpup8DF" // use com.evolutiongaming.crypto.Encrypt app to encrypt +password = "3-CYhqa9EgW+YAwO4+0yyYwf4Vi152kB3kKerbOnD9uGpl" // use com.evolutiongaming.crypto.Encrypt app to encrypt ``` Use the library as follows diff --git a/build.sbt b/build.sbt index 43302b0..70f0a81 100644 --- a/build.sbt +++ b/build.sbt @@ -14,11 +14,11 @@ bintrayOrganization := Some("evolutiongaming") scalaVersion := crossScalaVersions.value.head -crossScalaVersions := Seq("2.13.0", "2.12.9") +crossScalaVersions := Seq("2.13.1", "2.12.11") libraryDependencies ++= Seq( "com.typesafe" % "config" % "1.4.0", - "commons-codec" % "commons-codec" % "1.13" , + "commons-codec" % "commons-codec" % "1.14" , "org.scalatest" %% "scalatest" % "3.1.1" % Test ) diff --git a/project/plugins.sbt b/project/plugins.sbt index e6ca2e0..7a68ddb 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,10 +2,10 @@ externalResolvers += Resolver.bintrayIvyRepo("evolutiongaming", "sbt-plugins") addSbtPlugin("org.foundweekends" % "sbt-bintray" % "0.5.6") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1") addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.2.7") addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.13") -addSbtPlugin("com.evolutiongaming" % "sbt-scalac-opts-plugin" % "0.0.4") \ No newline at end of file +addSbtPlugin("com.evolutiongaming" % "sbt-scalac-opts-plugin" % "0.0.4") diff --git a/src/main/scala/com/evolutiongaming/crypto/Crypto.scala b/src/main/scala/com/evolutiongaming/crypto/Crypto.scala index 558310b..f8b4403 100644 --- a/src/main/scala/com/evolutiongaming/crypto/Crypto.scala +++ b/src/main/scala/com/evolutiongaming/crypto/Crypto.scala @@ -1,11 +1,12 @@ package com.evolutiongaming.crypto -import java.security.MessageDigest -import java.util.Base64 +import java.nio.charset.StandardCharsets.UTF_8 +import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.spec.{IvParameterSpec, SecretKeySpec} -import org.apache.commons.codec.binary.Hex +import javax.crypto.spec.{GCMParameterSpec, IvParameterSpec, SecretKeySpec} +import javax.crypto.{AEADBadTagException, Cipher} +import org.apache.commons.codec.binary.{Base64, Hex} +import org.apache.commons.codec.digest.DigestUtils /** * Copyright (C) 2009-2016 Lightbend Inc. @@ -13,137 +14,189 @@ import org.apache.commons.codec.binary.Hex * Based on https://github.com/playframework/playframework/blob/master/framework/src/play/src/main/scala/play/api/libs/Crypto.scala */ object Crypto { - val aesTransformation: String = "AES/CTR/NoPadding" + // max allowed length in bytes + // For AES we hardcode key length to 128bit minimum to not depend on environment security policy settings: + // it may vary between 128 and 256 bits which can yield different encryption keys if we don't val AesKeyBytesMaxSize: Int = 16 class AesKeyTooLong extends Exception( s"AES key should have size no more than $AesKeyBytesMaxSize bytes" ) + class DecryptAuthException(cause: Throwable) extends Exception( + "Decrypted value is not the original one, most likely wrong private key used for decryption", + cause, + ) + + /* + using lazy val to postpone init until the first usage so class loading does not get blocked + by obtaining entropy for the seed - same trick as in java.util.UUID.Holder + */ + private lazy val secureRandom = new SecureRandom /** - * Encrypt a String with the AES encryption standard and the supplied private key. - * - * - * The provider used is by default this uses the platform default JSSE provider. This can be overridden by defining - * `play.crypto.provider` in `application.conf`. + * Encrypts a string with the AES algorithm and the supplied private key - pair to the + * [[decryptAES]] method. * - * The transformation algorithm used is the provider specific implementation of the `AES` name. On Oracles JDK, - * this is `AES/CTR/NoPadding`. This algorithm is suitable for small amounts of data, typically less than 32 - * bytes, hence is useful for encrypting credit card numbers, passwords etc. For larger blocks of data, this - * algorithm may expose patterns and be vulnerable to repeat attacks. + * AES/GCM/NoPadding transformation is used with 128 bit key for authenticated encryption. + * The secret key entropy is obtained from the given private key by applying the SHA256 hash. * - * The transformation algorithm can be configured by defining `play.crypto.aes.transformation` in - * `application.conf`. Although any cipher transformation algorithm can be selected here, the secret key spec used - * is always AES, so only AES transformation algorithms will work. - * - * @param value The String to encrypt. - * @param privateKey The key used to encrypt. - * @return A Base64 encrypted string. + * @param value string value to encrypt + * @param privateKey private key string to use in encryption + * @return an encrypted string */ def encryptAES(value: String, privateKey: String): String = { - val skeySpec = secretKeyWithSha256(privateKey, "AES") - val cipher = getCipherWithConfiguredProvider(aesTransformation) - cipher.init(Cipher.ENCRYPT_MODE, skeySpec) - val encryptedValue = cipher.doFinal(value.getBytes("utf-8")) - // return a formatted, versioned encrypted string - // '2-*' represents an encrypted payload with an IV - // '1-*' represents an encrypted payload without an IV - Option(cipher.getIV) match { - case Some(iv) => s"2-${Base64.getEncoder.encodeToString(iv ++ encryptedValue)}" - case None => s"1-${Base64.getEncoder.encodeToString(encryptedValue)}" // will never fall here as CTR requires IV - } + s"3-${ AES_V3.encrypt(value, privateKey) }" } + /** - * Decrypt a String with the AES encryption standard. + * Decrypts a string with the AES algorithm and the supplied private key - pair to the + * [[encryptAES]] method. * - * The private key must have a length of 16 bytes. + * Additionally to the current [[encryptAES]] encryption mode, several legacy modes supported. * - * The provider used is by default this uses the platform default JSSE provider. This can be overridden by defining - * `play.crypto.provider` in `application.conf`. + * If the current [[encryptAES]] algorithm is used, it is guaranteed that if a decrypted value is returned + * it is the original one and the private key is valid. In case a wrong private key is used, an exception + * will be thrown. * - * The transformation used is by default `AES/CTR/NoPadding`. It can be configured by defining - * `play.crypto.aes.transformation` in `application.conf`. Although any cipher transformation algorithm can - * be selected here, the secret key spec used is always AES, so only AES transformation algorithms will work. - * - * @param value An hexadecimal encrypted string. - * @param privateKey The key used to encrypt. - * @return The decrypted String. + * @param value an encrypted string produced by the [[encryptAES]] method + * @param privateKey private key string used in encryption + * @return decrypted string */ def decryptAES(value: String, privateKey: String): String = { - val seperator = "-" - val sepIndex = value.indexOf(seperator) + val separator = "-" + val sepIndex = value.indexOf(separator) if (sepIndex < 0) { - decryptAESVersion0(value, privateKey) + AES_V0.decrypt(value, privateKey) } else { - val version = value.substring(0, sepIndex) - val data = value.substring(sepIndex + 1, value.length()) + val version = value.take(sepIndex) + val data = value.drop(sepIndex + 1) version match { case "1" => - decryptAESVersion1(data, privateKey) + AES_V1.decrypt(data, privateKey) case "2" => - decryptAESVersion2(data, privateKey) - case _ => + AES_V2.decrypt(data, privateKey) + case "3" => + AES_V3.decrypt(data, privateKey) + case _ => throw new RuntimeException("Unknown version") } } } - private def validateAesKeyLength(key: String): Unit = - require(key.getBytes("utf-8").length <= AesKeyBytesMaxSize, throw new AesKeyTooLong) + /** AES legacy V0 (no versioning) mode support - it has restrictions on key size */ + private object AES_V0 { + private val CipherAlgorithm = "AES" + private val CipherTransformation = "AES" + + def decrypt(value: String, privateKey: String): String = { + val privateKeyBytes = privateKey.getBytes(UTF_8) + require(privateKeyBytes.length <= AesKeyBytesMaxSize, throw new AesKeyTooLong) + val effectiveSecretKey = privateKeyBytes.take(AesKeyBytesMaxSize) + val skeySpec = new SecretKeySpec(effectiveSecretKey, CipherAlgorithm) + val cipher = Cipher.getInstance(CipherTransformation) + cipher.init(Cipher.DECRYPT_MODE, skeySpec) + new String(cipher.doFinal(Hex.decodeHex(value))) + } + } /** - * Transform an hexadecimal String to a byte array. - * From https://github.com/playframework/playframework/blob/master/framework/src/play/src/main/scala/play/api/libs/Codecs.scala + * AES legacy V1 mode support: + * - no restrictions on key size - SHA256 hash is used to obtain key entropy */ - private def hexStringToByte(hexString: String): Array[Byte] = Hex.decodeHex(hexString.toCharArray) - - /** Backward compatible AES ECB mode decryption support. */ - private def decryptAESVersion0(value: String, privateKey: String): String = { - validateAesKeyLength(privateKey) - val raw = privateKey.substring(0, AesKeyBytesMaxSize).getBytes("utf-8") - val skeySpec = new SecretKeySpec(raw, "AES") - val cipher = getCipherWithConfiguredProvider("AES") - cipher.init(Cipher.DECRYPT_MODE, skeySpec) - new String(cipher.doFinal(hexStringToByte(value))) - } + private object AES_V1 { + private val CipherTransformation = "AES" - /** V1 decryption algorithm (No IV). */ - private def decryptAESVersion1(value: String, privateKey: String): String = { - val data = Base64.getDecoder.decode(value) - val skeySpec = secretKeyWithSha256(privateKey, "AES") - val cipher = getCipherWithConfiguredProvider("AES") - cipher.init(Cipher.DECRYPT_MODE, skeySpec) - new String(cipher.doFinal(data), "utf-8") + def decrypt(value: String, privateKey: String): String = { + val data = Base64.decodeBase64(value) + val skeySpec = aesSKey128bitWithSha256(privateKey.getBytes(UTF_8)) + val cipher = Cipher.getInstance(CipherTransformation) + cipher.init(Cipher.DECRYPT_MODE, skeySpec) + new String(cipher.doFinal(data), UTF_8) + } } - /** V2 decryption algorithm (IV present). */ - private def decryptAESVersion2(value: String, privateKey: String): String = { - val data = Base64.getDecoder.decode(value) - val skeySpec = secretKeyWithSha256(privateKey, "AES") - val cipher = getCipherWithConfiguredProvider(aesTransformation) - val blockSize = cipher.getBlockSize - val iv = data.slice(0, blockSize) - val payload = data.slice(blockSize, data.size) - cipher.init(Cipher.DECRYPT_MODE, skeySpec, new IvParameterSpec(iv)) - new String(cipher.doFinal(payload), "utf-8") + /** + * AES legacy V1 mode support: + * - no restrictions on key size - SHA256 hash is used to obtain key entropy + * - AES/CTR/NoPadding (128 bit key) cipher with IV + */ + private object AES_V2 { + private val CipherTransformation = "AES/CTR/NoPadding" + + def decrypt(value: String, privateKey: String): String = { + val ivWithEncryptedData = Base64.decodeBase64(value) + val skeySpec = aesSKey128bitWithSha256(privateKey.getBytes(UTF_8)) + val cipher = Cipher.getInstance(CipherTransformation) + val blockSize = cipher.getBlockSize + if (ivWithEncryptedData.length < blockSize) { + throw new IllegalArgumentException("invalid data size") + } + val (iv, encryptedData) = ivWithEncryptedData.splitAt(blockSize) + cipher.init(Cipher.DECRYPT_MODE, skeySpec, new IvParameterSpec(iv)) + new String(cipher.doFinal(encryptedData), UTF_8) + } } /** - * Generates the SecretKeySpec, given the private key and the algorithm. + * Current AES mode - V3: + * - no restrictions on key size - SHA256 hash is used to obtain key entropy + * - AES/GCM/NoPadding (128 bit key) cipher is used to provide authenticated encryption */ - private def secretKeyWithSha256(privateKey: String, algorithm: String) = { - val messageDigest = MessageDigest.getInstance("SHA-256") - messageDigest.update(privateKey.getBytes("utf-8")) - // max allowed length in bits / (8 bits to a byte) - // For AES we hardcode keylength to 128bit minimum to not depend on environment security policy settings: - // it may vary between 128 and 256 bits which can yield different encryption keys if we don't - val maxAllowedKeyLength = if (algorithm == "AES") AesKeyBytesMaxSize else Cipher.getMaxAllowedKeyLength(algorithm) / 8 - val raw = messageDigest.digest().slice(0, maxAllowedKeyLength) - new SecretKeySpec(raw, algorithm) + private object AES_V3 { + /* + implementation based on + https://proandroiddev.com/security-best-practices-symmetric-encryption-with-aes-in-java-7616beaaade9 + */ + + private val CipherTransformation = "AES/GCM/NoPadding" + /* + same auth tag length as in + https://proandroiddev.com/security-best-practices-symmetric-encryption-with-aes-in-java-7616beaaade9 + */ + private val AuthTagLengthBits = 128 + /* + for GCM IV a 12 byte random byte-array is recommend by NIST + https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf + */ + private val IVLengthBytes = 12 + + def encrypt(value: String, privateKey: String): String = { + val skeySpec = aesSKey128bitWithSha256(privateKey.getBytes(UTF_8)) + val iv = new Array[Byte](IVLengthBytes) + secureRandom.nextBytes(iv) + val parameterSpec = new GCMParameterSpec(AuthTagLengthBits, iv) + val cipher = Cipher.getInstance(CipherTransformation) + cipher.init(Cipher.ENCRYPT_MODE, skeySpec, parameterSpec) + val encryptedValue = cipher.doFinal(value.getBytes(UTF_8)) + Base64.encodeBase64String(iv ++ encryptedValue) + } + + def decrypt(value: String, privateKey: String): String = { + val ivWithEncryptedData = Base64.decodeBase64(value) + val skeySpec = aesSKey128bitWithSha256(privateKey.getBytes(UTF_8)) + val cipher = Cipher.getInstance(CipherTransformation) + if (ivWithEncryptedData.length < IVLengthBytes) { + throw new IllegalArgumentException("invalid data size") + } + val (iv, encryptedData) = ivWithEncryptedData.splitAt(IVLengthBytes) + cipher.init(Cipher.DECRYPT_MODE, skeySpec, new GCMParameterSpec(AuthTagLengthBits, iv)) + val decryptedValue = try { + cipher.doFinal(encryptedData) + } catch { + case e: AEADBadTagException => throw new DecryptAuthException(e) + } + new String(decryptedValue, UTF_8) + } } - private def getCipherWithConfiguredProvider(transformation: String) = { - Cipher.getInstance(transformation) + /** + * Creates a SecretKeySpec instance for an AES algorithm with an 128 bit key produced from SHA256 hash of + * the private key data. + */ + private def aesSKey128bitWithSha256(privateKeyBytes: Array[Byte]): SecretKeySpec = { + val privateKeyDigest = DigestUtils.sha256(privateKeyBytes) + val effectiveSecretKey = privateKeyDigest.take(16) //128 bit = 16 bytes + new SecretKeySpec(effectiveSecretKey, "AES") } } diff --git a/src/main/scala/com/evolutiongaming/crypto/DecryptConfig.scala b/src/main/scala/com/evolutiongaming/crypto/DecryptConfig.scala index 271e2b0..2636fa8 100644 --- a/src/main/scala/com/evolutiongaming/crypto/DecryptConfig.scala +++ b/src/main/scala/com/evolutiongaming/crypto/DecryptConfig.scala @@ -1,18 +1,21 @@ package com.evolutiongaming.crypto import com.typesafe.config.{Config, ConfigFactory} -import scala.util.control.NonFatal object DecryptConfig { - def apply(password: String, config: Config = ConfigFactory.load()): String = try { - if (config getBoolean "encryptedPasswords") { - val secret = config getString "application.secret" + private val EncryptedPasswordsPath = "encryptedPasswords" + private val AppSecretPath = "application.secret" + + def apply(password: String, config: Config = ConfigFactory.load()): String = { + if ( + config.hasPath(EncryptedPasswordsPath) && + config.getBoolean(EncryptedPasswordsPath) + ) { + val secret = config getString AppSecretPath Crypto.decryptAES(password, secret.substring(0, 16)) } else { password } - } catch { - case NonFatal(_) => password } implicit class DecryptConfigOps(val self: Config) extends AnyVal { @@ -20,11 +23,9 @@ object DecryptConfig { def decryptString(encrypted: String): String = apply(encrypted, self) - def decryptPath(path: String): String = try { + def decryptPath(path: String): String = { val encrypted = self.getString(path) apply(encrypted, self) - } catch { - case NonFatal(_) => path } } } diff --git a/src/test/resources/bad-secret.conf b/src/test/resources/bad-secret.conf index ef9641f..709d1cb 100644 --- a/src/test/resources/bad-secret.conf +++ b/src/test/resources/bad-secret.conf @@ -3,4 +3,4 @@ application { secret = "badSecretVeryBad" } -password = "2-DpBV9t/8a19P5o0fohf//Lpup8DF" +password = "3-Nsik1A3L3qlpDNTG0fzJhDx/SchtDXeCRThNN9UW4Vf1" diff --git a/src/test/resources/encrypted.conf b/src/test/resources/encrypted.conf index e8cca74..a65fa32 100644 --- a/src/test/resources/encrypted.conf +++ b/src/test/resources/encrypted.conf @@ -3,4 +3,4 @@ application { secret = "abcdefghijklmnop" } -password = "2-DpBV9t/8a19P5o0fohf//Lpup8DF" +password = "3-Nsik1A3L3qlpDNTG0fzJhDx/SchtDXeCRThNN9UW4Vf1" diff --git a/src/test/scala/com/evolutiongaming/crypto/CryptoSpec.scala b/src/test/scala/com/evolutiongaming/crypto/CryptoSpec.scala index a1de2f8..f2e739d 100644 --- a/src/test/scala/com/evolutiongaming/crypto/CryptoSpec.scala +++ b/src/test/scala/com/evolutiongaming/crypto/CryptoSpec.scala @@ -40,25 +40,15 @@ class CryptoSpec extends AnyFlatSpec with Matchers { original shouldEqual decrypted } - it should "not give same result when encryption and decryption keys are different" in { + it should "fail on decryption when encryption and decryption keys are different" in { val original = "test data please ignore" val key = "1234567890123456" val otherKey = "6543210987654321" val encrypted = Crypto.encryptAES(original, key) - val decrypted = Crypto.decryptAES(encrypted, otherKey) - - original should not equal decrypted - } - - it should "not give same result when encryption and decryption keys are different (long and substring)" in { - val original = "test data please ignore" - val key = "1234567890123456now_it_became_too_long" - - val encrypted = Crypto.encryptAES(original, key) - val decrypted = Crypto.decryptAES(encrypted, key.take(16)) - - original should not equal decrypted + assertThrows[Crypto.DecryptAuthException] { + Crypto.decryptAES(encrypted, otherKey) + } } // backward compatibility tests @@ -107,5 +97,21 @@ class CryptoSpec extends AnyFlatSpec with Matchers { val encrypted = "2-aNJt/st3SsxhFYQ/ybgpM9vudiHQjUf1JqJD" Crypto.decryptAES(encrypted, key) shouldEqual original } + + it should "decrypt with key up to 16 bytes (v3)" in { + val key = "1234567890123456" + val original = "secretvalue" + + val encrypted = "3-3ziyv9x8EG5Uq9jOVminWlA3huF8IEMFPw8aWiOavK6RqYfKgkel" + Crypto.decryptAES(encrypted, key) shouldEqual original + } + + it should "decrypt with key bigger than 16 bytes (v3)" in { + val key = "1234567890123456" + "now_it_became_too_long" + val original = "secretvalue" + + val encrypted = "3-mGkG+PNuDPW390xxtcTMsUB22ppJfQebazmBcsvERfMveNSVwI8q" + Crypto.decryptAES(encrypted, key) shouldEqual original + } // enb of backward compatibility tests } diff --git a/src/test/scala/com/evolutiongaming/crypto/DecryptConfigSpec.scala b/src/test/scala/com/evolutiongaming/crypto/DecryptConfigSpec.scala index 01fe0ce..a5bbc85 100644 --- a/src/test/scala/com/evolutiongaming/crypto/DecryptConfigSpec.scala +++ b/src/test/scala/com/evolutiongaming/crypto/DecryptConfigSpec.scala @@ -1,8 +1,8 @@ package com.evolutiongaming.crypto +import com.evolutiongaming.crypto.DecryptConfig.DecryptConfigOps import com.typesafe.config.ConfigFactory import org.scalatest.BeforeAndAfterEach -import DecryptConfig.DecryptConfigOps import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers @@ -32,14 +32,15 @@ class DecryptConfigSpec extends AnyFlatSpec with BeforeAndAfterEach with Matcher } it should "work with plain AES call" in { - val password = "2-DpBV9t/8a19P5o0fohf//Lpup8DF" - val secret = "abcdefghijklmnop" + val password = "3-Nsik1A3L3qlpDNTG0fzJhDx/SchtDXeCRThNN9UW4Vf1" + val secret = "abcdefghijklmnop" Crypto.decryptAES(password, secret) shouldEqual correctPassword } it should "fail with bad secret" in { - val decrypted = decrypt("bad-secret.conf") - decrypted should not equal correctPassword + intercept[Crypto.DecryptAuthException] { + decrypt("bad-secret.conf") + } } it should "decrypt encrypted passwords by path (Config extension method)" in { diff --git a/version.sbt b/version.sbt index b96de5d..af2011c 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "1.5.2-SNAPSHOT" +version in ThisBuild := "1.6-SNAPSHOT"