Skip to content

Commit

Permalink
Merge fdacdbd into 0f653d6
Browse files Browse the repository at this point in the history
  • Loading branch information
migesok committed Mar 27, 2020
2 parents 0f653d6 + fdacdbd commit 472ff46
Show file tree
Hide file tree
Showing 11 changed files with 271 additions and 137 deletions.
6 changes: 3 additions & 3 deletions .travis.yml
Expand Up @@ -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

Expand All @@ -29,4 +29,4 @@ cache:

before_cache:
- find $HOME/.ivy2/cache -name "ivydata-*.properties" -print -delete
- find $HOME/.sbt -name "*.lock" -print -delete
- find $HOME/.sbt -name "*.lock" -print -delete
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -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`:

Expand All @@ -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-DG4i9kr/lboBjhjgwMsT/2f1Jc6vI4O9VucM+ucM7TDi9Q==" // use com.evolutiongaming.crypto.Encrypt app to encrypt
```

Use the library as follows
Expand Down
4 changes: 2 additions & 2 deletions build.sbt
Expand Up @@ -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
)

Expand Down
4 changes: 2 additions & 2 deletions project/plugins.sbt
Expand Up @@ -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")
addSbtPlugin("com.evolutiongaming" % "sbt-scalac-opts-plugin" % "0.0.4")
268 changes: 172 additions & 96 deletions src/main/scala/com/evolutiongaming/crypto/Crypto.scala
@@ -1,149 +1,225 @@
package com.evolutiongaming.crypto

import java.security.MessageDigest
import java.util.Base64
import java.nio.ByteBuffer
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. <https://www.lightbend.com>
*
* 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
* - dynamic length random IV - 12 bytes by default with possible extension up to 255 bytes
* - 128 bit auth tag length
*/
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 CurrentIVLengthBytes = 12
private val MinIVLengthBytes = 12 //does not allow decrypting with IVs smaller than 12 bytes (96 bits)

def encrypt(value: String, privateKey: String): String = {
val skeySpec = aesSKey128bitWithSha256(privateKey.getBytes(UTF_8))
val iv = new Array[Byte](CurrentIVLengthBytes)
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))
encodeEncryptedToString(iv, encryptedValue)
}

private def encodeEncryptedToString(iv: Array[Byte], encryptedValue: Array[Byte]): String = {
//1 byte for dynamic IV length encoding
val buf = ByteBuffer.allocate(1 + iv.length + encryptedValue.length)
//encode IV length as an unsigned byte
buf.put(iv.length.toByte)
buf.put(iv)
buf.put(encryptedValue)
Base64.encodeBase64String(buf.array())
}

def decrypt(value: String, privateKey: String): String = {
val payload = Base64.decodeBase64(value)
val skeySpec = aesSKey128bitWithSha256(privateKey.getBytes(UTF_8))
val cipher = Cipher.getInstance(CipherTransformation)
val ivLength = readValidIvLength(payload)

val ivOffset = 1 //1 byte for encoded IV length
val ivEndIdx = ivOffset + ivLength
require(payload.length >= ivEndIdx, "invalid data size")
val gcmParamSpec = new GCMParameterSpec(AuthTagLengthBits, payload, ivOffset, ivLength)
cipher.init(Cipher.DECRYPT_MODE, skeySpec, gcmParamSpec)
val decryptInputLength = payload.length - ivEndIdx
val decryptedValue = try {
cipher.doFinal(payload, ivEndIdx, decryptInputLength)
} catch {
case e: AEADBadTagException => throw new DecryptAuthException(e)
}
new String(decryptedValue, UTF_8)
}

private def readValidIvLength(payload: Array[Byte]): Int = {
require(payload.length > 0, "invalid data size")
val ivLength = java.lang.Byte.toUnsignedInt(payload(0))
require(ivLength >= MinIVLengthBytes, s"IV length shouldn't be smaller than $MinIVLengthBytes bytes")
ivLength
}
}

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")
}
}

0 comments on commit 472ff46

Please sign in to comment.