diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..faa8204 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,34 @@ +name: Docker +on: + push: + schedule: + - cron: '54 2 2 * *' + workflow_dispatch: +jobs: + buildDockerImage: + name: Build Docker image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker meta + id: meta + uses: docker/metadata-action@v3 + with: + images: ghcr.io/wisvch/feedback-tool + tags: type=sha, prefix={{date 'YYYYMMDD'}}- + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + push: ${{ github.ref == 'refs/heads/master' }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c460259..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: java -services: - - docker -jdk: -- openjdk11 -before_cache: -- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock -- rm -fr $HOME/.gradle/caches/*/plugin-resolution/ -cache: - directories: - - "$HOME/.gradle/caches/" - - "$HOME/.gradle/wrapper/" -script: -- "./gradlew check" -deploy: - provider: script - script: >- - sh -c ' - docker build --no-cache --pull -t quay.io/wisvch/feedback-tool:$TRAVIS_BUILD_NUMBER .; - docker login -u "$QUAY_USERNAME" -p "$QUAY_PASSWORD" quay.io; - docker push quay.io/wisvch/feedback-tool:$TRAVIS_BUILD_NUMBER; - ' - on: - branch: master -notifications: - email: false - slack: - rooms: - secure: uCLBLnOEWsOHarQOZZTc3bXsV8vLvaZYMbXWfDFJgJ6+HPPa+XN+BbHdCyIosyGsxuON6qzFDkbiopc2mK98f3GtCKwOpgfc7y6oU0Gev621qDihnE4U+QGO88rl3Oi9Y8j6m8MbtTrA2rnywCl+mWM4wSRpIVTYscYeSibxjVe4Yznag+GaJLnPWgPK+wV/kS97wdaCygqhpW63y3hQdoT4O8nvuwQXIq/K3wqqpdGZGNMJgHQI0uI9hyAlKqzdg8ttb09wHXcImofr43tnsaAfOsxz3y4Vl1kLLyPWokCewV5idpVhyToHsRxvWJrkE4ym9g2Gav2ksQt6Jd2wHvFNhqRqlJQWmypTBp5rq81uoCyirlYT2nt57LUCXJd9gCZlKendaOS4Gxo7aBu8HYceIjwAucDIVsxIOksMjTlnEdqXG/fdEuPMsCYXJKFgvANynN6vln6fbKR16TBuwp+kZ2iBpqm6/TvVaCrkV3dir1yrzt4Car54LPzAWsR7NC49gNVxYceoYwkcFQo49FPiTrM1Jf6REkbVJcLz0xuCUmW+w7h/jupuFcba2LRJScc97U6IjoguBwuPlNAk2C2wFLtLMlqo7XeKdpdKHnFJtwRVd+ySwZpehUuDcJlK3SP9HzWebPsoEtmtpQjRHuxNClnQH8qqEgHoIorpMHk= diff --git a/Dockerfile b/Dockerfile index b8fed82..9fd7a5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM wisvch/openjdk:11-jdk AS builder +FROM openjdk:11-jdk-buster AS builder COPY . /src WORKDIR /src RUN ./gradlew build diff --git a/build.gradle b/build.gradle index 95f51c6..99b7782 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,7 @@ dependencies { compile 'org.projectlombok:lombok' compile 'org.json:json:20180813' + compile 'org.apache.commons:commons-lang3:3.7' runtime 'org.postgresql:postgresql' runtime 'com.h2database:h2' diff --git a/src/main/java/ch/wisv/converters/AbstractCryptoConverter.java b/src/main/java/ch/wisv/converters/AbstractCryptoConverter.java new file mode 100644 index 0000000..81a0944 --- /dev/null +++ b/src/main/java/ch/wisv/converters/AbstractCryptoConverter.java @@ -0,0 +1,202 @@ +package ch.wisv.converters; + +import static ch.wisv.converters.KeyProperty.DATABASE_ENCRYPTION_KEY; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.persistence.AttributeConverter; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; + +/** + * AbstractCryptoConverter class. + * + * @param + */ +abstract class AbstractCryptoConverter implements AttributeConverter { + + /** + * Used for concatenating cipher text and iv together. + * This will not cause issues when the text contains hashtags, + * since a Base64 encoded string cannot contain hashtags. + * https://en.wikipedia.org/wiki/Base64#Base64_table + */ + private static final String CONCATENATION = "####"; + + /** CipherInitializer. */ + private CipherInitializer cipherInitializer; + + /** + * AbstractCryptoConverter constructor. + */ + public AbstractCryptoConverter() { + this(new CipherInitializer()); + } + + /** + * AbstractCryptoConverter constructor. + * + * @param cipherInitializer of type CipherInitializer + */ + public AbstractCryptoConverter(CipherInitializer cipherInitializer) { + this.cipherInitializer = cipherInitializer; + } + + /** + * Convert th entity attribute to the database data. + * + * @param attribute of type T + * + * @return String + */ + @Override + public String convertToDatabaseColumn(T attribute) { + if (isNotEmpty(DATABASE_ENCRYPTION_KEY) && isNotNullOrEmpty(attribute)) { + try { + Cipher cipher = cipherInitializer.prepareAndInitCipher(Cipher.ENCRYPT_MODE, DATABASE_ENCRYPTION_KEY, null); + + return this.concatenatedCipherTextAndIv(this.encrypt(cipher, attribute), cipher.getIV()); + } catch (NoSuchAlgorithmException | InvalidKeyException | InvalidAlgorithmParameterException | BadPaddingException | + NoSuchPaddingException | IllegalBlockSizeException e) { + throw new RuntimeException(e); + } + } + + return this.entityAttributeToString(attribute); + } + + /** + * Convert the database data to the entity attribute. + * + * @param dbData of type String. + * + * @return T + */ + @Override + public T convertToEntityAttribute(String dbData) { + if (isNotEmpty(DATABASE_ENCRYPTION_KEY) && isNotEmpty(dbData)) { + try { + Pair cipherTextAndIv = this.splitDbData(dbData); + Cipher cipher = cipherInitializer.prepareAndInitCipher(Cipher.DECRYPT_MODE, DATABASE_ENCRYPTION_KEY, cipherTextAndIv.getRight()); + + return this.decrypt(cipher, cipherTextAndIv.getLeft()); + } catch (NoSuchAlgorithmException | InvalidKeyException | InvalidAlgorithmParameterException | BadPaddingException | + NoSuchPaddingException | IllegalBlockSizeException e) { + throw new RuntimeException(e); + } + } + + return this.stringToEntityAttribute(dbData); + } + + /** + * Concatenated cipher text and IV together. + * + * @param cipherText of type String + * @param iv of type byte[] + * + * @return String + */ + private String concatenatedCipherTextAndIv(String cipherText, byte[] iv) { + return cipherText + CONCATENATION + Base64.getEncoder().encodeToString(iv); + } + + /** + * Split db data into cipher text and IV. + * + * @param dbData of type String + * + * @return Pair + */ + private Pair splitDbData(String dbData) { + String[] splitDbData = dbData.split(CONCATENATION); + String cipherText = splitDbData[0]; + byte[] iv = Base64.getDecoder().decode(splitDbData[1]); + + return new ImmutablePair<>(cipherText, iv); + } + + /** + * Do final Cipher call. + * + * @param cipher of type String + * @param bytes of type byte[] + * + * @return byte[] + * + * @throws IllegalBlockSizeException when the block size is wrong + * @throws BadPaddingException when the padding is wrong + */ + private byte[] callCipherDoFinal(Cipher cipher, byte[] bytes) throws IllegalBlockSizeException, BadPaddingException { + return cipher.doFinal(bytes); + } + + /** + * Decrypt database data with a give cipher. + * + * @param cipher of type Cipher + * @param dbData of type String + * + * @return T + * + * @throws IllegalBlockSizeException when the block size is wrong + * @throws BadPaddingException when the padding is wrong + */ + private T decrypt(Cipher cipher, String dbData) throws IllegalBlockSizeException, BadPaddingException { + byte[] encryptedBytes = Base64.getDecoder().decode(dbData); + byte[] decryptedBytes = this.callCipherDoFinal(cipher, encryptedBytes); + + return this.stringToEntityAttribute(new String(decryptedBytes)); + } + + /** + * Encrypt attribute with a give cipher. + * + * @param cipher of type Cipher + * @param attribute of type T + * + * @return String + * + * @throws IllegalBlockSizeException when the block size is wrong + * @throws BadPaddingException when the padding is wrong + */ + private String encrypt(Cipher cipher, T attribute) throws IllegalBlockSizeException, BadPaddingException { + byte[] bytesToEncrypt = this.entityAttributeToString(attribute).getBytes(); + byte[] encryptedBytes = this.callCipherDoFinal(cipher, bytesToEncrypt); + + return Base64.getEncoder().encodeToString(encryptedBytes); + } + + /** + * Check attribute is not null or empty. + * + * @param attribute of type T + * + * @return boolean + */ + abstract boolean isNotNullOrEmpty(T attribute); + + /** + * Convert database data to entity attribute. + * + * @param dbData of type String + * + * @return T + */ + abstract T stringToEntityAttribute(String dbData); + + /** + * Convert entity attribute to database data. + * + * @param attribute of type T + * + * @return String + */ + abstract String entityAttributeToString(T attribute); +} diff --git a/src/main/java/ch/wisv/converters/CipherInitializer.java b/src/main/java/ch/wisv/converters/CipherInitializer.java new file mode 100644 index 0000000..3338fee --- /dev/null +++ b/src/main/java/ch/wisv/converters/CipherInitializer.java @@ -0,0 +1,67 @@ +package ch.wisv.converters; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.AlgorithmParameterSpec; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * CipherInitializer. + */ +class CipherInitializer { + + /** Encryption method. */ + private static final String CIPHER_INSTANCE_NAME = "AES/CBC/PKCS5Padding"; + + /** Secret key algorithm. */ + private static final String SECRET_KEY_ALGORITHM = "AES"; + + /** + * Creates an IvParameterSpec object using the bytes in iv as the IV. + * The IV is fixed such that we do not have to save the IV. + * TODO: Random IV to improve security. + * + * @param cipher of type Cipher + * + * @return AlgorithmParameterSpec + */ + private AlgorithmParameterSpec getRandomIv(Cipher cipher) { + SecureRandom randomSecureRandom = new SecureRandom(); + byte[] iv = new byte[cipher.getBlockSize()]; + randomSecureRandom.nextBytes(iv); + + return new IvParameterSpec(iv); + } + + /** + * Prepare Cipher by adding the key and creating an IV. + * + * @param encryptionMode of type int + * @param key of type String + * @param iv of type byte[] + * + * @return Cipher + * + * @throws InvalidKeyException when key is invalid + * @throws NoSuchPaddingException when padding method does not exists + * @throws NoSuchAlgorithmException when the algorithm does not exists + * @throws InvalidAlgorithmParameterException when the mode of operation does not exists + */ + Cipher prepareAndInitCipher(int encryptionMode, String key, byte[] iv) + throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException { + Cipher cipher = Cipher.getInstance(CIPHER_INSTANCE_NAME); + Key secretKey = new SecretKeySpec(key.getBytes(), SECRET_KEY_ALGORITHM); + + AlgorithmParameterSpec algorithmParameters = iv == null ? this.getRandomIv(cipher) : new IvParameterSpec(iv); + cipher.init(encryptionMode, secretKey, algorithmParameters); + + return cipher; + } +} diff --git a/src/main/java/ch/wisv/converters/KeyProperty.java b/src/main/java/ch/wisv/converters/KeyProperty.java new file mode 100644 index 0000000..e0f0afe --- /dev/null +++ b/src/main/java/ch/wisv/converters/KeyProperty.java @@ -0,0 +1,23 @@ +package ch.wisv.converters; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * KeyProperty component. + */ +@Component +public class KeyProperty { + + /** Encryption key. */ + static String DATABASE_ENCRYPTION_KEY; + + /** + * Set encryption key. + */ + @Value("${wisvch.database.encryption.key}") + public void setDatabase(String databaseEncryptionKey) { + DATABASE_ENCRYPTION_KEY = databaseEncryptionKey; + } + +} diff --git a/src/main/java/ch/wisv/converters/StringCryptoConverter.java b/src/main/java/ch/wisv/converters/StringCryptoConverter.java new file mode 100644 index 0000000..e4fe3fd --- /dev/null +++ b/src/main/java/ch/wisv/converters/StringCryptoConverter.java @@ -0,0 +1,63 @@ +package ch.wisv.converters; + +import javax.persistence.Converter; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; + +/** + * StringCryptoConverter. + */ +@Converter +public class StringCryptoConverter extends AbstractCryptoConverter { + + /** + * StringCryptoConverter constructor. + */ + public StringCryptoConverter() { + this(new CipherInitializer()); + } + + /** + * StringCryptoConverter constructor. + * + * @param cipherInitializer of type CipherInitializer + */ + public StringCryptoConverter(CipherInitializer cipherInitializer) { + super(cipherInitializer); + } + + /** + * Implementation of isNotNullOrEmpty. + * + * @param attribute of type T + * + * @return boolean + */ + @Override + boolean isNotNullOrEmpty(String attribute) { + return isNotEmpty(attribute); + } + + /** + * Implementation of stringToEntityAttribute. + * + * @param dbData of type String + * + * @return String + */ + @Override + String stringToEntityAttribute(String dbData) { + return dbData; + } + + /** + * Implementation of entityAttributeToString. + * + * @param attribute of type T + * + * @return String + */ + @Override + String entityAttributeToString(String attribute) { + return attribute; + } +} diff --git a/src/main/java/ch/wisv/domain/course/Instructor.java b/src/main/java/ch/wisv/domain/course/Instructor.java index caf6267..2bb0430 100644 --- a/src/main/java/ch/wisv/domain/course/Instructor.java +++ b/src/main/java/ch/wisv/domain/course/Instructor.java @@ -1,13 +1,13 @@ package ch.wisv.domain.course; -import lombok.Data; - +import ch.wisv.converters.StringCryptoConverter; +import java.util.List; +import javax.persistence.Convert; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.ManyToMany; -import javax.validation.constraints.NotEmpty; -import java.util.List; +import lombok.Data; /** * Created by Tom on 14/05/2017. @@ -20,9 +20,11 @@ public class Instructor { private long id; @NotEmpty + @Convert(converter = StringCryptoConverter.class) private String name; @NotEmpty + @Convert(converter = StringCryptoConverter.class) private String mail; @ManyToMany(mappedBy = "instructors") diff --git a/src/test/java/ch/wisv/converters/StringCryptoConverterTest.java b/src/test/java/ch/wisv/converters/StringCryptoConverterTest.java new file mode 100644 index 0000000..4487f0b --- /dev/null +++ b/src/test/java/ch/wisv/converters/StringCryptoConverterTest.java @@ -0,0 +1,27 @@ +package ch.wisv.converters; + +import static org.junit.Assert.assertEquals; +import org.junit.Before; +import org.junit.Test; + +public class StringCryptoConverterTest { + + private StringCryptoConverter cryptoConverter; + + @Before + public void setUp() { + KeyProperty.DATABASE_ENCRYPTION_KEY = "KaPdSgVkYp3s6v12"; + CipherInitializer cipherInitializer = new CipherInitializer(); + this.cryptoConverter = new StringCryptoConverter(cipherInitializer); + } + + @Test + public void testEncryptDecrypt() { + String message = "this is a secret message"; + + String cipherPlusIv = cryptoConverter.convertToDatabaseColumn(message); + String decryptedCipher = cryptoConverter.convertToEntityAttribute(cipherPlusIv); + + assertEquals(message, decryptedCipher); + } +}