diff options
author | Zipeng WU <zipeng.wu@sonarsource.com> | 2021-02-11 18:25:14 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-02-17 20:07:15 +0000 |
commit | 122edd4683e3019c8035c40c53c8813e855372f0 (patch) | |
tree | f69986d0d45f2ef08ffff760b223fff28b63f1dc | |
parent | d90fced6c38073a22b76ef7b3c6b834ca21c7418 (diff) | |
download | sonarqube-122edd4683e3019c8035c40c53c8813e855372f0.tar.gz sonarqube-122edd4683e3019c8035c40c53c8813e855372f0.zip |
SONAR-14426 Add support for AES-GCM encryption
16 files changed, 319 insertions, 477 deletions
diff --git a/server/sonar-docs/src/images/encrypt-value.png b/server/sonar-docs/src/images/encrypt-value.png Binary files differindex c22aa1dc2d7..c71737322d4 100644 --- a/server/sonar-docs/src/images/encrypt-value.png +++ b/server/sonar-docs/src/images/encrypt-value.png diff --git a/server/sonar-docs/src/pages/instance-administration/security.md b/server/sonar-docs/src/pages/instance-administration/security.md index c7acc9cc0e3..436257e3dda 100644 --- a/server/sonar-docs/src/pages/instance-administration/security.md +++ b/server/sonar-docs/src/pages/instance-administration/security.md @@ -195,7 +195,7 @@ Go back to **[Administration > Configuration > Encryption](/#sonarqube-admin#/ad 1. **Use the encrypted values in your SonarQube server configuration** Simply copy these encrypted values into _$SONARQUBE-HOME/conf/sonar.properties_ ``` -sonar.jdbc.password={aes}CCGCFg4Xpm6r+PiJb1Swfg== # Encrypted DB password +sonar.jdbc.password={aes-gcm}CCGCFg4Xpm6r+PiJb1Swfg== # Encrypted DB password ... sonar.secretKeyPath=C:/path/to/my/secure/location/my_secret_key.txt ``` diff --git a/server/sonar-process/src/main/java/org/sonar/process/AesCipher.java b/server/sonar-process/src/main/java/org/sonar/process/AesCipher.java deleted file mode 100644 index 129858fad94..00000000000 --- a/server/sonar-process/src/main/java/org/sonar/process/AesCipher.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.process; - -import java.io.File; -import java.io.IOException; -import java.security.Key; -import javax.annotation.Nullable; -import javax.crypto.spec.SecretKeySpec; -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang.StringUtils; - -import static java.nio.charset.StandardCharsets.UTF_8; - -final class AesCipher implements Cipher { - private static final String CRYPTO_KEY = "AES"; - - /** - * Duplication from CoreProperties.ENCRYPTION_SECRET_KEY_PATH - */ - static final String ENCRYPTION_SECRET_KEY_PATH = "sonar.secretKeyPath"; - - private String pathToSecretKey; - - AesCipher(@Nullable String pathToSecretKey) { - this.pathToSecretKey = pathToSecretKey; - } - - @Override - public String encrypt(String clearText) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_KEY); - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, loadSecretFile()); - return Base64.encodeBase64String(cipher.doFinal(clearText.getBytes(UTF_8))); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - - @Override - public String decrypt(String encryptedText) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_KEY); - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, loadSecretFile()); - byte[] cipherData = cipher.doFinal(Base64.decodeBase64(StringUtils.trim(encryptedText))); - return new String(cipherData, UTF_8); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - - /** - * This method checks the existence of the file, but not the validity of the contained key. - */ - boolean hasSecretKey() { - String path = getPathToSecretKey(); - if (StringUtils.isNotBlank(path)) { - File file = new File(path); - return file.exists() && file.isFile(); - } - return false; - } - - private Key loadSecretFile() throws IOException { - String path = getPathToSecretKey(); - return loadSecretFileFromFile(path); - } - - Key loadSecretFileFromFile(String path) throws IOException { - if (StringUtils.isBlank(path)) { - throw new IllegalStateException("Secret key not found. Please set the property " + ENCRYPTION_SECRET_KEY_PATH); - } - File file = new File(path); - if (!file.exists() || !file.isFile()) { - throw new IllegalStateException("The property " + ENCRYPTION_SECRET_KEY_PATH + " does not link to a valid file: " + path); - } - String s = FileUtils.readFileToString(file, UTF_8); - if (StringUtils.isBlank(s)) { - throw new IllegalStateException("No secret key in the file: " + path); - } - return new SecretKeySpec(Base64.decodeBase64(StringUtils.trim(s)), CRYPTO_KEY); - } - - String getPathToSecretKey() { - if (StringUtils.isBlank(pathToSecretKey)) { - pathToSecretKey = new File(System.getProperty("user.home"), ".sonar/sonar-secret.txt").getPath(); - } - return pathToSecretKey; - } -} diff --git a/server/sonar-process/src/main/java/org/sonar/process/Encryption.java b/server/sonar-process/src/main/java/org/sonar/process/Encryption.java deleted file mode 100644 index a2763997f09..00000000000 --- a/server/sonar-process/src/main/java/org/sonar/process/Encryption.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.process; - -import javax.annotation.Nullable; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * @since 3.0 - */ -public final class Encryption { - - private static final String BASE64_ALGORITHM = "b64"; - - private static final String AES_ALGORITHM = "aes"; - private final AesCipher aesCipher; - - private final Map<String, Cipher> ciphers = new HashMap<>(); - private static final Pattern ENCRYPTED_PATTERN = Pattern.compile("\\{(.*?)\\}(.*)"); - - public Encryption(@Nullable String pathToSecretKey) { - aesCipher = new AesCipher(pathToSecretKey); - ciphers.put(BASE64_ALGORITHM, new Base64Cipher()); - ciphers.put(AES_ALGORITHM, aesCipher); - } - - public boolean isEncrypted(String value) { - return value.indexOf('{') == 0 && value.indexOf('}') > 1; - } - - public String decrypt(String encryptedText) { - Matcher matcher = ENCRYPTED_PATTERN.matcher(encryptedText); - if (matcher.matches()) { - Cipher cipher = ciphers.get(matcher.group(1).toLowerCase(Locale.ENGLISH)); - if (cipher != null) { - return cipher.decrypt(matcher.group(2)); - } - } - return encryptedText; - } - -} diff --git a/server/sonar-process/src/main/java/org/sonar/process/Props.java b/server/sonar-process/src/main/java/org/sonar/process/Props.java index f5294a54812..b88cbad38b0 100644 --- a/server/sonar-process/src/main/java/org/sonar/process/Props.java +++ b/server/sonar-process/src/main/java/org/sonar/process/Props.java @@ -23,7 +23,11 @@ import java.io.File; import java.util.Properties; import javax.annotation.CheckForNull; import javax.annotation.Nullable; + import org.apache.commons.lang.StringUtils; +import org.sonar.api.config.internal.Encryption; + +import static org.sonar.api.CoreProperties.ENCRYPTION_SECRET_KEY_PATH; public class Props { @@ -33,7 +37,7 @@ public class Props { public Props(Properties props) { this.properties = new Properties(); props.forEach((k, v) -> this.properties.put(k.toString().trim(), v == null ? null : v.toString().trim())); - this.encryption = new Encryption(props.getProperty(AesCipher.ENCRYPTION_SECRET_KEY_PATH)); + this.encryption = new Encryption(props.getProperty(ENCRYPTION_SECRET_KEY_PATH)); } public boolean contains(String key) { diff --git a/server/sonar-process/src/test/java/org/sonar/process/AesCipherTest.java b/server/sonar-process/src/test/java/org/sonar/process/AesCipherTest.java deleted file mode 100644 index 3687ee39d11..00000000000 --- a/server/sonar-process/src/test/java/org/sonar/process/AesCipherTest.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.process; - -import com.google.common.io.Resources; -import java.io.File; -import java.security.InvalidKeyException; -import java.security.Key; -import javax.crypto.BadPaddingException; -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.lang.StringUtils; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.fail; - -public class AesCipherTest { - - @Rule - public ExpectedException thrown = ExpectedException.none(); - - @Test - public void encrypt() { - AesCipher cipher = new AesCipher(pathToSecretKey()); - - String encryptedText = cipher.encrypt("this is a secret"); - - assertThat(StringUtils.isNotBlank(encryptedText)).isTrue(); - assertThat(Base64.isBase64(encryptedText.getBytes())).isTrue(); - } - - @Test - public void encrypt_bad_key() { - thrown.expect(RuntimeException.class); - thrown.expectMessage("Invalid AES key"); - - AesCipher cipher = new AesCipher(getPath("bad_secret_key.txt")); - - cipher.encrypt("this is a secret"); - } - - @Test - public void decrypt() { - AesCipher cipher = new AesCipher(pathToSecretKey()); - - // the following value has been encrypted with the key /org/sonar/api/config/AesCipherTest/aes_secret_key.txt - String clearText = cipher.decrypt("9mx5Zq4JVyjeChTcVjEide4kWCwusFl7P2dSVXtg9IY="); - - assertThat(clearText).isEqualTo("this is a secret"); - } - - @Test - public void decrypt_bad_key() { - AesCipher cipher = new AesCipher(getPath("bad_secret_key.txt")); - - try { - cipher.decrypt("9mx5Zq4JVyjeChTcVjEide4kWCwusFl7P2dSVXtg9IY="); - fail(); - - } catch (RuntimeException e) { - assertThat(e.getCause()).isInstanceOf(InvalidKeyException.class); - } - } - - @Test - public void decrypt_other_key() { - AesCipher cipher = new AesCipher(getPath("other_secret_key.txt")); - - try { - // text encrypted with another key - cipher.decrypt("9mx5Zq4JVyjeChTcVjEide4kWCwusFl7P2dSVXtg9IY="); - fail(); - - } catch (RuntimeException e) { - assertThat(e.getCause()).isInstanceOf(BadPaddingException.class); - } - } - - @Test - public void encryptThenDecrypt() { - AesCipher cipher = new AesCipher(pathToSecretKey()); - - assertThat(cipher.decrypt(cipher.encrypt("foo"))).isEqualTo("foo"); - } - - @Test - public void testDefaultPathToSecretKey() { - AesCipher cipher = new AesCipher(null); - - String path = cipher.getPathToSecretKey(); - - assertThat(StringUtils.isNotBlank(path)).isTrue(); - assertThat(new File(path).getName()).isEqualTo("sonar-secret.txt"); - } - - @Test - public void loadSecretKeyFromFile() throws Exception { - AesCipher cipher = new AesCipher(null); - Key secretKey = cipher.loadSecretFileFromFile(pathToSecretKey()); - assertThat(secretKey.getAlgorithm()).isEqualTo("AES"); - assertThat(secretKey.getEncoded().length).isGreaterThan(10); - } - - @Test - public void loadSecretKeyFromFile_trim_content() throws Exception { - String path = getPath("non_trimmed_secret_key.txt"); - AesCipher cipher = new AesCipher(null); - - Key secretKey = cipher.loadSecretFileFromFile(path); - - assertThat(secretKey.getAlgorithm()).isEqualTo("AES"); - assertThat(secretKey.getEncoded().length).isGreaterThan(10); - } - - @Test - public void loadSecretKeyFromFile_file_does_not_exist() throws Exception { - thrown.expect(IllegalStateException.class); - - AesCipher cipher = new AesCipher(null); - cipher.loadSecretFileFromFile("/file/does/not/exist"); - } - - @Test - public void loadSecretKeyFromFile_no_property() throws Exception { - thrown.expect(IllegalStateException.class); - - AesCipher cipher = new AesCipher(null); - cipher.loadSecretFileFromFile(null); - } - - @Test - public void hasSecretKey() { - AesCipher cipher = new AesCipher(pathToSecretKey()); - - assertThat(cipher.hasSecretKey()).isTrue(); - } - - @Test - public void doesNotHaveSecretKey() { - AesCipher cipher = new AesCipher("/my/twitter/id/is/SimonBrandhof"); - - assertThat(cipher.hasSecretKey()).isFalse(); - } - - private static String getPath(String file) { - return Resources.getResource(AesCipherTest.class, "AesCipherTest/" + file).getPath(); - } - - private static String pathToSecretKey() { - return getPath("aes_secret_key.txt"); - } - -} diff --git a/server/sonar-process/src/test/java/org/sonar/process/EncryptionTest.java b/server/sonar-process/src/test/java/org/sonar/process/EncryptionTest.java deleted file mode 100644 index 956d97dbebb..00000000000 --- a/server/sonar-process/src/test/java/org/sonar/process/EncryptionTest.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.process; - -import org.junit.Test; - -import static org.assertj.core.api.Assertions.assertThat; - - -public class EncryptionTest { - - @Test - public void isEncrypted() { - Encryption encryption = new Encryption(null); - assertThat(encryption.isEncrypted("{aes}ADASDASAD")).isTrue(); - assertThat(encryption.isEncrypted("{b64}ADASDASAD")).isTrue(); - assertThat(encryption.isEncrypted("{abc}ADASDASAD")).isTrue(); - - assertThat(encryption.isEncrypted("{}")).isFalse(); - assertThat(encryption.isEncrypted("{foo")).isFalse(); - assertThat(encryption.isEncrypted("foo{aes}")).isFalse(); - } - - @Test - public void decrypt() { - Encryption encryption = new Encryption(null); - assertThat(encryption.decrypt("{b64}Zm9v")).isEqualTo("foo"); - } - - @Test - public void decrypt_unknown_algorithm() { - Encryption encryption = new Encryption(null); - assertThat(encryption.decrypt("{xxx}Zm9v")).isEqualTo("{xxx}Zm9v"); - } - - @Test - public void decrypt_uncrypted_text() { - Encryption encryption = new Encryption(null); - assertThat(encryption.decrypt("foo")).isEqualTo("foo"); - } -} diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/setting/ws/encrypt-example.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/setting/ws/encrypt-example.json index ae4fd4cfca4..e42a6be672d 100644 --- a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/setting/ws/encrypt-example.json +++ b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/setting/ws/encrypt-example.json @@ -1,3 +1,3 @@ { - "encryptedValue": "{aes}q2ANI9ikR9R8P2CMCCTWeA==" + "encryptedValue": "{aes-gcm}q2ANI9ikR9R8P2CMCCTWeA==" } diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/setting/ws/EncryptActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/setting/ws/EncryptActionTest.java index 4bf7c859c90..70876c252ab 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/setting/ws/EncryptActionTest.java +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/setting/ws/EncryptActionTest.java @@ -22,6 +22,7 @@ package org.sonar.server.setting.ws; import java.io.File; import java.nio.charset.StandardCharsets; import javax.annotation.Nullable; + import org.apache.commons.io.FileUtils; import org.junit.Before; import org.junit.Rule; @@ -40,7 +41,6 @@ import org.sonarqube.ws.Settings.EncryptWsResponse; import static org.assertj.core.api.Assertions.assertThat; import static org.sonar.server.setting.ws.SettingsWsParameters.PARAM_VALUE; -import static org.sonar.test.JsonAssert.assertJson; public class EncryptActionTest { @Rule @@ -66,21 +66,12 @@ public class EncryptActionTest { } @Test - public void json_example() { - logInAsSystemAdministrator(); - - String result = ws.newRequest().setParam("value", "my value").execute().getInput(); - - assertJson(result).isSimilarTo(ws.getDef().responseExampleAsString()); - } - - @Test public void encrypt() { logInAsSystemAdministrator(); EncryptWsResponse result = call("my value!"); - assertThat(result.getEncryptedValue()).isEqualTo("{aes}NoofntibpMBdhkMfXQxYcA=="); + assertThat(result.getEncryptedValue()).matches("^\\{aes-gcm\\}.+"); } @Test diff --git a/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesCipher.java b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesCipher.java index 3df06736202..57541227658 100644 --- a/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesCipher.java +++ b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesCipher.java @@ -21,7 +21,6 @@ package org.sonar.api.config.internal; import java.io.File; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.security.Key; import java.security.SecureRandom; import javax.annotation.Nullable; @@ -34,8 +33,9 @@ import org.apache.commons.lang.StringUtils; import org.sonar.api.CoreProperties; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.sonar.api.CoreProperties.ENCRYPTION_SECRET_KEY_PATH; -final class AesCipher implements Cipher { +abstract class AesCipher implements Cipher { static final int KEY_SIZE_IN_BITS = 256; private static final String CRYPTO_KEY = "AES"; @@ -46,33 +46,6 @@ final class AesCipher implements Cipher { this.pathToSecretKey = pathToSecretKey; } - @Override - public String encrypt(String clearText) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_KEY); - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, loadSecretFile()); - return Base64.encodeBase64String(cipher.doFinal(clearText.getBytes(StandardCharsets.UTF_8.name()))); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - - @Override - public String decrypt(String encryptedText) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_KEY); - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, loadSecretFile()); - byte[] cipherData = cipher.doFinal(Base64.decodeBase64(StringUtils.trim(encryptedText))); - return new String(cipherData, StandardCharsets.UTF_8); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - /** * This method checks the existence of the file, but not the validity of the contained key. */ @@ -85,18 +58,18 @@ final class AesCipher implements Cipher { return false; } - private Key loadSecretFile() throws IOException { + protected Key loadSecretFile() throws IOException { String path = getPathToSecretKey(); return loadSecretFileFromFile(path); } Key loadSecretFileFromFile(@Nullable String path) throws IOException { if (StringUtils.isBlank(path)) { - throw new IllegalStateException("Secret key not found. Please set the property " + CoreProperties.ENCRYPTION_SECRET_KEY_PATH); + throw new IllegalStateException("Secret key not found. Please set the property " + ENCRYPTION_SECRET_KEY_PATH); } File file = new File(path); if (!file.exists() || !file.isFile()) { - throw new IllegalStateException("The property " + CoreProperties.ENCRYPTION_SECRET_KEY_PATH + " does not link to a valid file: " + path); + throw new IllegalStateException("The property " + ENCRYPTION_SECRET_KEY_PATH + " does not link to a valid file: " + path); } String s = FileUtils.readFileToString(file, UTF_8); if (StringUtils.isBlank(s)) { diff --git a/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesECBCipher.java b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesECBCipher.java new file mode 100644 index 00000000000..ff8aada450e --- /dev/null +++ b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesECBCipher.java @@ -0,0 +1,67 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.api.config.internal; + +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang.StringUtils; + +/** + * @deprecated since 8.7.0 + */ +@Deprecated +final class AesECBCipher extends AesCipher { + + private static final String CRYPTO_ALGO = "AES"; + + AesECBCipher(@Nullable String pathToSecretKey) { + super(pathToSecretKey); + } + + @Override + public String encrypt(String clearText) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_ALGO); + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, loadSecretFile()); + byte[] cipherData = cipher.doFinal(clearText.getBytes(StandardCharsets.UTF_8.name())); + return Base64.encodeBase64String(cipherData); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Override + public String decrypt(String encryptedText) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_ALGO); + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, loadSecretFile()); + byte[] cipherData = cipher.doFinal(Base64.decodeBase64(StringUtils.trim(encryptedText))); + return new String(cipherData, StandardCharsets.UTF_8); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesGCMCipher.java b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesGCMCipher.java new file mode 100644 index 00000000000..2f8f86d8d3f --- /dev/null +++ b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/AesGCMCipher.java @@ -0,0 +1,79 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.api.config.internal; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import javax.annotation.Nullable; +import javax.crypto.spec.GCMParameterSpec; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang.StringUtils; + +final class AesGCMCipher extends AesCipher { + private static final int GCM_TAG_LENGTH_IN_BITS = 128; + private static final int GCM_IV_LENGTH_IN_BYTES = 12; + + private static final String CRYPTO_ALGO = "AES/GCM/NoPadding"; + + AesGCMCipher(@Nullable String pathToSecretKey) { + super(pathToSecretKey); + } + + @Override + public String encrypt(String clearText) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_ALGO); + byte[] iv = new byte[GCM_IV_LENGTH_IN_BYTES]; + new SecureRandom().nextBytes(iv); + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, loadSecretFile(), new GCMParameterSpec(GCM_TAG_LENGTH_IN_BITS, iv)); + byte[] encryptedText = cipher.doFinal(clearText.getBytes(StandardCharsets.UTF_8.name())); + return Base64.encodeBase64String( + ByteBuffer.allocate(GCM_IV_LENGTH_IN_BYTES + encryptedText.length) + .put(iv) + .put(encryptedText) + .array()); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Override + public String decrypt(String encryptedText) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CRYPTO_ALGO); + ByteBuffer byteBuffer = ByteBuffer.wrap(Base64.decodeBase64(StringUtils.trim(encryptedText))); + byte[] iv = new byte[GCM_IV_LENGTH_IN_BYTES]; + byteBuffer.get(iv); + byte[] cipherText = new byte[byteBuffer.remaining()]; + byteBuffer.get(cipherText); + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, loadSecretFile(), new GCMParameterSpec(GCM_TAG_LENGTH_IN_BITS, iv)); + byte[] cipherData = cipher.doFinal(cipherText); + return new String(cipherData, StandardCharsets.UTF_8); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/Encryption.java b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/Encryption.java index c5542301678..23c38aa88aa 100644 --- a/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/Encryption.java +++ b/sonar-plugin-api-impl/src/main/java/org/sonar/api/config/internal/Encryption.java @@ -32,29 +32,34 @@ import javax.annotation.Nullable; public final class Encryption { private static final String BASE64_ALGORITHM = "b64"; + private static final String AES_ECB_ALGORITHM = "aes"; + private static final String AES_GCM_ALGORITHM = "aes-gcm"; - private static final String AES_ALGORITHM = "aes"; - private final AesCipher aesCipher; + private final AesECBCipher aesECBCipher; + private final AesGCMCipher aesGCMCipher; private final Map<String, Cipher> ciphers; private static final Pattern ENCRYPTED_PATTERN = Pattern.compile("\\{(.*?)\\}(.*)"); public Encryption(@Nullable String pathToSecretKey) { - aesCipher = new AesCipher(pathToSecretKey); + aesECBCipher = new AesECBCipher(pathToSecretKey); + aesGCMCipher = new AesGCMCipher(pathToSecretKey); ciphers = new HashMap<>(); ciphers.put(BASE64_ALGORITHM, new Base64Cipher()); - ciphers.put(AES_ALGORITHM, aesCipher); + ciphers.put(AES_ECB_ALGORITHM, aesECBCipher); + ciphers.put(AES_GCM_ALGORITHM, aesGCMCipher); } public void setPathToSecretKey(@Nullable String pathToSecretKey) { - aesCipher.setPathToSecretKey(pathToSecretKey); + aesECBCipher.setPathToSecretKey(pathToSecretKey); + aesGCMCipher.setPathToSecretKey(pathToSecretKey); } /** * Checks the availability of the secret key, that is required to encrypt and decrypt. */ public boolean hasSecretKey() { - return aesCipher.hasSecretKey(); + return aesGCMCipher.hasSecretKey(); } public boolean isEncrypted(String value) { @@ -62,7 +67,7 @@ public final class Encryption { } public String encrypt(String clearText) { - return encrypt(AES_ALGORITHM, clearText); + return encrypt(AES_GCM_ALGORITHM, clearText); } public String scramble(String clearText) { @@ -70,7 +75,7 @@ public final class Encryption { } public String generateRandomSecretKey() { - return aesCipher.generateRandomSecretKey(); + return aesGCMCipher.generateRandomSecretKey(); } public String decrypt(String encryptedText) { diff --git a/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesCipherTest.java b/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesECBCipherTest.java index b9cfc356b34..28de7d573b0 100644 --- a/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesCipherTest.java +++ b/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesECBCipherTest.java @@ -33,14 +33,14 @@ import org.junit.rules.ExpectedException; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.fail; -public class AesCipherTest { +public class AesECBCipherTest { @Rule public ExpectedException thrown = ExpectedException.none(); @Test public void generateRandomSecretKey() { - AesCipher cipher = new AesCipher(null); + AesECBCipher cipher = new AesECBCipher(null); String key = cipher.generateRandomSecretKey(); @@ -50,7 +50,7 @@ public class AesCipherTest { @Test public void encrypt() throws Exception { - AesCipher cipher = new AesCipher(pathToSecretKey()); + AesECBCipher cipher = new AesECBCipher(pathToSecretKey()); String encryptedText = cipher.encrypt("this is a secret"); @@ -64,14 +64,14 @@ public class AesCipherTest { thrown.expectMessage("Invalid AES key"); URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/bad_secret_key.txt"); - AesCipher cipher = new AesCipher(new File(resource.toURI()).getCanonicalPath()); + AesECBCipher cipher = new AesECBCipher(new File(resource.toURI()).getCanonicalPath()); cipher.encrypt("this is a secret"); } @Test public void decrypt() throws Exception { - AesCipher cipher = new AesCipher(pathToSecretKey()); + AesECBCipher cipher = new AesECBCipher(pathToSecretKey()); // the following value has been encrypted with the key /org/sonar/api/config/internal/AesCipherTest/aes_secret_key.txt String clearText = cipher.decrypt("9mx5Zq4JVyjeChTcVjEide4kWCwusFl7P2dSVXtg9IY="); @@ -82,7 +82,7 @@ public class AesCipherTest { @Test public void decrypt_bad_key() throws Exception { URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/bad_secret_key.txt"); - AesCipher cipher = new AesCipher(new File(resource.toURI()).getCanonicalPath()); + AesECBCipher cipher = new AesECBCipher(new File(resource.toURI()).getCanonicalPath()); try { cipher.decrypt("9mx5Zq4JVyjeChTcVjEide4kWCwusFl7P2dSVXtg9IY="); @@ -96,7 +96,7 @@ public class AesCipherTest { @Test public void decrypt_other_key() throws Exception { URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/other_secret_key.txt"); - AesCipher cipher = new AesCipher(new File(resource.toURI()).getCanonicalPath()); + AesECBCipher cipher = new AesECBCipher(new File(resource.toURI()).getCanonicalPath()); try { // text encrypted with another key @@ -110,46 +110,46 @@ public class AesCipherTest { @Test public void encryptThenDecrypt() throws Exception { - AesCipher cipher = new AesCipher(pathToSecretKey()); + AesECBCipher cipher = new AesECBCipher(pathToSecretKey()); assertThat(cipher.decrypt(cipher.encrypt("foo"))).isEqualTo("foo"); } @Test public void testDefaultPathToSecretKey() { - AesCipher cipher = new AesCipher(null); + AesECBCipher cipher = new AesECBCipher(null); String path = cipher.getPathToSecretKey(); assertThat(StringUtils.isNotBlank(path)).isTrue(); - assertThat(new File(path).getName()).isEqualTo("sonar-secret.txt"); + assertThat(new File(path)).hasName("sonar-secret.txt"); } @Test public void loadSecretKeyFromFile() throws Exception { - AesCipher cipher = new AesCipher(null); + AesECBCipher cipher = new AesECBCipher(null); Key secretKey = cipher.loadSecretFileFromFile(pathToSecretKey()); assertThat(secretKey.getAlgorithm()).isEqualTo("AES"); - assertThat(secretKey.getEncoded().length).isGreaterThan(10); + assertThat(secretKey.getEncoded()).hasSizeGreaterThan(10); } @Test public void loadSecretKeyFromFile_trim_content() throws Exception { URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/non_trimmed_secret_key.txt"); String path = new File(resource.toURI()).getCanonicalPath(); - AesCipher cipher = new AesCipher(null); + AesECBCipher cipher = new AesECBCipher(null); Key secretKey = cipher.loadSecretFileFromFile(path); assertThat(secretKey.getAlgorithm()).isEqualTo("AES"); - assertThat(secretKey.getEncoded().length).isGreaterThan(10); + assertThat(secretKey.getEncoded()).hasSizeGreaterThan(10); } @Test public void loadSecretKeyFromFile_file_does_not_exist() throws Exception { thrown.expect(IllegalStateException.class); - AesCipher cipher = new AesCipher(null); + AesECBCipher cipher = new AesECBCipher(null); cipher.loadSecretFileFromFile("/file/does/not/exist"); } @@ -157,20 +157,20 @@ public class AesCipherTest { public void loadSecretKeyFromFile_no_property() throws Exception { thrown.expect(IllegalStateException.class); - AesCipher cipher = new AesCipher(null); + AesECBCipher cipher = new AesECBCipher(null); cipher.loadSecretFileFromFile(null); } @Test public void hasSecretKey() throws Exception { - AesCipher cipher = new AesCipher(pathToSecretKey()); + AesECBCipher cipher = new AesECBCipher(pathToSecretKey()); assertThat(cipher.hasSecretKey()).isTrue(); } @Test public void doesNotHaveSecretKey() { - AesCipher cipher = new AesCipher("/my/twitter/id/is/SimonBrandhof"); + AesECBCipher cipher = new AesECBCipher("/my/twitter/id/is/SimonBrandhof"); assertThat(cipher.hasSecretKey()).isFalse(); } diff --git a/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesGCMCipherTest.java b/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesGCMCipherTest.java new file mode 100644 index 00000000000..08b23b19476 --- /dev/null +++ b/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/AesGCMCipherTest.java @@ -0,0 +1,93 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.api.config.internal; + +import java.io.File; +import java.net.URL; +import java.security.InvalidKeyException; +import javax.crypto.BadPaddingException; + +import org.apache.commons.lang.StringUtils; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class AesGCMCipherTest { + + @Test + public void encrypt_should_generate_different_value_everytime() throws Exception { + AesGCMCipher cipher = new AesGCMCipher(pathToSecretKey()); + + String encryptedText1 = cipher.encrypt("this is a secret"); + String encryptedText2 = cipher.encrypt("this is a secret"); + + assertThat(StringUtils.isNotBlank(encryptedText1)).isTrue(); + assertThat(StringUtils.isNotBlank(encryptedText2)).isTrue(); + assertThat(encryptedText1).isNotEqualTo(encryptedText2); + } + + @Test + public void encrypt_bad_key() throws Exception { + URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/bad_secret_key.txt"); + AesGCMCipher cipher = new AesGCMCipher(new File(resource.toURI()).getCanonicalPath()); + + assertThatThrownBy(() -> cipher.encrypt("this is a secret")) + .hasRootCauseInstanceOf(InvalidKeyException.class) + .hasMessageContaining("Invalid AES key"); + } + + @Test + public void decrypt() throws Exception { + AesGCMCipher cipher = new AesGCMCipher(pathToSecretKey()); + String input1 = "this is a secret"; + String input2 = "asdkfja;ksldjfowiaqueropijadfskncmnv/sdjflskjdflkjiqoeuwroiqu./qewirouasoidfhjaskldfhjkhckjnkiuoewiruoasdjkfalkufoiwueroijuqwoerjsdkjflweoiru"; + + assertThat(cipher.decrypt(cipher.encrypt(input1))).isEqualTo(input1); + assertThat(cipher.decrypt(cipher.encrypt(input1))).isEqualTo(input1); + assertThat(cipher.decrypt(cipher.encrypt(input2))).isEqualTo(input2); + assertThat(cipher.decrypt(cipher.encrypt(input2))).isEqualTo(input2); + } + + @Test + public void decrypt_bad_key() throws Exception { + URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/bad_secret_key.txt"); + AesGCMCipher cipher = new AesGCMCipher(new File(resource.toURI()).getCanonicalPath()); + + assertThatThrownBy(() -> cipher.decrypt("9mx5Zq4JVyjeChTcVjEide4kWCwusFl7P2dSVXtg9IY=")) + .hasRootCauseInstanceOf(InvalidKeyException.class) + .hasMessageContaining("Invalid AES key"); + } + + @Test + public void decrypt_other_key() throws Exception { + URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/other_secret_key.txt"); + AesGCMCipher originalCipher = new AesGCMCipher(pathToSecretKey()); + AesGCMCipher cipher = new AesGCMCipher(new File(resource.toURI()).getCanonicalPath()); + + assertThatThrownBy(() -> cipher.decrypt(originalCipher.encrypt("this is a secret"))) + .hasRootCauseInstanceOf(BadPaddingException.class); + } + + private String pathToSecretKey() throws Exception { + URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/aes_secret_key.txt"); + return new File(resource.toURI()).getCanonicalPath(); + } +} diff --git a/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/EncryptionTest.java b/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/EncryptionTest.java index 12c8d716368..63f202c2864 100644 --- a/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/EncryptionTest.java +++ b/sonar-plugin-api-impl/src/test/java/org/sonar/api/config/internal/EncryptionTest.java @@ -19,6 +19,9 @@ */ package org.sonar.api.config.internal; +import java.io.File; +import java.net.URL; + import org.junit.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -50,6 +53,33 @@ public class EncryptionTest { } @Test + public void loadSecretKey() throws Exception { + Encryption encryption = new Encryption(null); + encryption.setPathToSecretKey(pathToSecretKey()); + assertThat(encryption.hasSecretKey()).isTrue(); + } + + @Test + public void generate_secret_key() { + Encryption encryption = new Encryption(null); + String key1 = encryption.generateRandomSecretKey(); + String key2 = encryption.generateRandomSecretKey(); + assertThat(key1).isNotEqualTo(key2); + } + + @Test + public void gcm_encryption() throws Exception { + Encryption encryption = new Encryption(pathToSecretKey()); + String clearText = "this is a secrit"; + String cipherText = encryption.encrypt(clearText); + String decryptedText = encryption.decrypt(cipherText); + assertThat(cipherText) + .startsWith("{aes-gcm}") + .isNotEqualTo(clearText); + assertThat(decryptedText).isEqualTo(clearText); + } + + @Test public void decrypt_unknown_algorithm() { Encryption encryption = new Encryption(null); assertThat(encryption.decrypt("{xxx}Zm9v")).isEqualTo("{xxx}Zm9v"); @@ -60,4 +90,9 @@ public class EncryptionTest { Encryption encryption = new Encryption(null); assertThat(encryption.decrypt("foo")).isEqualTo("foo"); } + + private String pathToSecretKey() throws Exception { + URL resource = getClass().getResource("/org/sonar/api/config/internal/AesCipherTest/aes_secret_key.txt"); + return new File(resource.toURI()).getCanonicalPath(); + } } |