From f227a6cca410b92c712c09a230f315fbb973b8aa Mon Sep 17 00:00:00 2001 From: Adrian Gonzalez Date: Tue, 7 May 2013 20:16:48 -0400 Subject: [PATCH] Added password encryption (fixes #1)) --- .../stash/hook/DefaultPasswordEncryptor.java | 115 ++++++++++++++++ .../stash/hook/MirrorRepositoryHook.java | 57 +++++++- .../stash/hook/PasswordEncryptor.java | 18 +++ src/main/resources/atlassian-plugin.xml | 4 + .../hook/DefaultPasswordEncryptorTest.java | 129 ++++++++++++++++++ .../stash/hook/MirrorRepositoryHookTest.java | 17 ++- 6 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/englishtown/stash/hook/DefaultPasswordEncryptor.java create mode 100644 src/main/java/com/englishtown/stash/hook/PasswordEncryptor.java create mode 100644 src/test/java/com/englishtown/stash/hook/DefaultPasswordEncryptorTest.java diff --git a/src/main/java/com/englishtown/stash/hook/DefaultPasswordEncryptor.java b/src/main/java/com/englishtown/stash/hook/DefaultPasswordEncryptor.java new file mode 100644 index 0000000..1f85d2b --- /dev/null +++ b/src/main/java/com/englishtown/stash/hook/DefaultPasswordEncryptor.java @@ -0,0 +1,115 @@ +package com.englishtown.stash.hook; + +import com.atlassian.sal.api.pluginsettings.PluginSettings; +import org.apache.commons.codec.binary.Base64; + +import javax.crypto.*; +import javax.crypto.spec.SecretKeySpec; +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +/** + * Service to encrypt/decrypt git user passwords + */ +public class DefaultPasswordEncryptor implements PasswordEncryptor { + + private SecretKey secretKey; + + public static final String ENCRYPTED_PREFIX = "encrypted:"; + public static final String SETTINGS_CRYPTO_KEY = "crypto.key"; + + @Override + public void init(PluginSettings pluginSettings) { + + try { + String keyBase64; + Object value = pluginSettings.get(SETTINGS_CRYPTO_KEY); + + if (value == null || value.toString().isEmpty()) { + KeyGenerator gen = KeyGenerator.getInstance("AES"); + secretKey = gen.generateKey(); + keyBase64 = Base64.encodeBase64String(secretKey.getEncoded()); + pluginSettings.put(SETTINGS_CRYPTO_KEY, keyBase64); + } else { + keyBase64 = value.toString(); + byte[] data = Base64.decodeBase64(keyBase64); + secretKey = new SecretKeySpec(data, 0, data.length, "AES"); + } + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + + } + + protected byte[] runCipher(byte[] data, boolean encrypt) { + + try { + int mode = encrypt ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE; + Cipher cipher = getCipher(mode); + return cipher.doFinal(data); + + } catch (IllegalBlockSizeException e) { + throw new RuntimeException(e); + } catch (BadPaddingException e) { + throw new RuntimeException(e); + } + } + + private Cipher getCipher(int mode) { + + try { + // Create Cipher + Cipher cipher = Cipher.getInstance("AES"); + + // Initialize Cipher with key + cipher.init(mode, secretKey); + return cipher; + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } catch (NoSuchPaddingException e) { + throw new RuntimeException(e); + } catch (InvalidKeyException e) { + throw new RuntimeException(e); + } + + } + + @Override + public boolean isEncrypted(String password) { + if (password == null || password.isEmpty()) { + return false; + } + return password.startsWith(ENCRYPTED_PREFIX); + } + + @Override + public String encrypt(String password) { + if (isEncrypted(password)) { + return password; + } + try { + byte[] encryptedData = runCipher(password.getBytes("UTF-8"), true); + return ENCRYPTED_PREFIX + Base64.encodeBase64String(encryptedData); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + @Override + public String decrypt(String password) { + if (!isEncrypted(password)) { + return password; + } + try { + byte[] encryptedData = Base64.decodeBase64(password.substring(ENCRYPTED_PREFIX.length())); + byte[] clearData = runCipher(encryptedData, false); + return new String(clearData, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/englishtown/stash/hook/MirrorRepositoryHook.java b/src/main/java/com/englishtown/stash/hook/MirrorRepositoryHook.java index 6cf3944..484d923 100644 --- a/src/main/java/com/englishtown/stash/hook/MirrorRepositoryHook.java +++ b/src/main/java/com/englishtown/stash/hook/MirrorRepositoryHook.java @@ -1,5 +1,7 @@ package com.englishtown.stash.hook; +import com.atlassian.sal.api.pluginsettings.PluginSettings; +import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; import com.atlassian.stash.hook.repository.AsyncPostReceiveRepositoryHook; import com.atlassian.stash.hook.repository.RepositoryHookContext; import com.atlassian.stash.i18n.I18nService; @@ -16,15 +18,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; +import java.lang.reflect.Field; import java.net.URI; import java.net.URISyntaxException; import java.util.Collection; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, RepositorySettingsValidator { + public static final String PLUGIN_SETTINGS_KEY = "com.englishtown.stash.hook.mirror"; static final String SETTING_MIRROR_REPO_URL = "mirrorRepoUrl"; static final String SETTING_USERNAME = "username"; static final String SETTING_PASSWORD = "password"; @@ -33,15 +39,29 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep private final GitScm gitScm; private final I18nService i18nService; private final ScheduledExecutorService executor; + private final PasswordEncryptor passwordEncryptor; private static final Logger logger = LoggerFactory.getLogger(MirrorRepositoryHook.class); public MirrorRepositoryHook( GitScm gitScm, I18nService i18nService, - ScheduledExecutorService executor) { + ScheduledExecutorService executor, + PasswordEncryptor passwordEncryptor, + PluginSettingsFactory pluginSettingsFactory + ) { + logger.debug("MirrorRepositoryHook: init started"); + + // Set fields this.gitScm = gitScm; this.i18nService = i18nService; this.executor = executor; + this.passwordEncryptor = passwordEncryptor; + + // Init password encryptor + PluginSettings pluginSettings = pluginSettingsFactory.createSettingsForKey(PLUGIN_SETTINGS_KEY); + passwordEncryptor.init(pluginSettings); + + logger.debug("MirrorRepositoryHook: init completed"); } /** @@ -71,9 +91,10 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep } - void runMirrorCommand(String mirrorRepoUrl, String username, final String password, final Repository repository) { + void runMirrorCommand(String mirrorRepoUrl, String username, final String encryptedPassword, final Repository repository) { try { + final String password = passwordEncryptor.decrypt(encryptedPassword); final URI authenticatedUrl = getAuthenticatedUrl(mirrorRepoUrl, username, password); executor.submit(new Callable() { @@ -179,6 +200,7 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep // If no errors, run the mirror command if (count == 0) { + encryptPassword(settings, SETTING_PASSWORD, password); runMirrorCommand(mirrorRepoUrl, username, password, repository); } logger.debug("MirrorRepositoryHook: validate completed with {} error(s).", count); @@ -190,4 +212,35 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep } + void encryptPassword(Settings settings, String settingName, String password) { + + // Skip if already encrypted + if (passwordEncryptor.isEncrypted(password)) { + return; + } + + try { + // Unfortunately the settings are stored in an immutable map, so need to cheat with reflection + logger.info("Encrypting password"); + Field field = settings.getClass().getDeclaredField("values"); + field.setAccessible(true); + + // Create mutable copy of values, and encrypt the password + @SuppressWarnings("unchecked") + Map values = (Map) field.get(settings); + values = new HashMap(values); + values.put(settingName, passwordEncryptor.encrypt(password)); + + field.set(settings, values); + + } catch (NoSuchFieldException e) { + throw new RuntimeException("Unable to encrypt the password. Check for an updated version of the mirror " + + "hook.", e); + } catch (IllegalAccessException e) { + throw new RuntimeException("Unable to encrypt the password. Check for an updated version of the mirror " + + "hook.", e); + } + + } + } \ No newline at end of file diff --git a/src/main/java/com/englishtown/stash/hook/PasswordEncryptor.java b/src/main/java/com/englishtown/stash/hook/PasswordEncryptor.java new file mode 100644 index 0000000..7b23ef6 --- /dev/null +++ b/src/main/java/com/englishtown/stash/hook/PasswordEncryptor.java @@ -0,0 +1,18 @@ +package com.englishtown.stash.hook; + +import com.atlassian.sal.api.pluginsettings.PluginSettings; + +/** + * Service to encrypt/decrypt git user passwords + */ +public interface PasswordEncryptor { + + void init(PluginSettings pluginSettings); + + boolean isEncrypted(String password); + + String encrypt(String password); + + String decrypt(String password); + +} diff --git a/src/main/resources/atlassian-plugin.xml b/src/main/resources/atlassian-plugin.xml index fd4defe..5a4e77d 100644 --- a/src/main/resources/atlassian-plugin.xml +++ b/src/main/resources/atlassian-plugin.xml @@ -11,6 +11,10 @@ + + + com.englishtown.stash.hook.PasswordEncryptor + diff --git a/src/test/java/com/englishtown/stash/hook/DefaultPasswordEncryptorTest.java b/src/test/java/com/englishtown/stash/hook/DefaultPasswordEncryptorTest.java new file mode 100644 index 0000000..def9378 --- /dev/null +++ b/src/test/java/com/englishtown/stash/hook/DefaultPasswordEncryptorTest.java @@ -0,0 +1,129 @@ +package com.englishtown.stash.hook; + +import com.atlassian.sal.api.pluginsettings.PluginSettings; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * DefaultPasswordEncryptor unit tests + */ +@RunWith(MockitoJUnitRunner.class) +public class DefaultPasswordEncryptorTest { + + private final static String CRYPTO_KEY = "m3ys5YexQc7irRlmJeCwAw=="; + + @Mock + private PluginSettings pluginSettings; + + private DefaultPasswordEncryptor encryptor; + + @Before + public void setUp() throws Exception { + + when(pluginSettings.get(DefaultPasswordEncryptor.SETTINGS_CRYPTO_KEY)).thenReturn(CRYPTO_KEY); + + encryptor = new DefaultPasswordEncryptor(); + encryptor.init(pluginSettings); + + } + + @Test + public void testInit() throws Exception { + + DefaultPasswordEncryptor encryptor = new DefaultPasswordEncryptor(); + PluginSettings pluginSettings = mock(PluginSettings.class); + encryptor.init(pluginSettings); + + verify(pluginSettings).put(eq(DefaultPasswordEncryptor.SETTINGS_CRYPTO_KEY), anyString()); + + when(pluginSettings.get(DefaultPasswordEncryptor.SETTINGS_CRYPTO_KEY)).thenReturn(CRYPTO_KEY); + + encryptor.init(pluginSettings); + + // Verify put hasn't been called again + verify(pluginSettings).put(eq(DefaultPasswordEncryptor.SETTINGS_CRYPTO_KEY), anyString()); + + } + + @Test + public void testRunCipher() throws Exception { + + DefaultPasswordEncryptor encryptor = new DefaultPasswordEncryptor(); + encryptor.init(pluginSettings); + + String clearText = "clear text"; + byte[] clearData = clearText.getBytes(); + byte[] encryptedData; + byte[] resultData; + String resultText; + + encryptedData = encryptor.runCipher(clearData, true); + + resultData = encryptor.runCipher(encryptedData, false); + resultText = new String(resultData); + + assertArrayEquals(clearData, resultData); + assertEquals(clearText, resultText); + + } + + @Test + public void testIsEncrypted() throws Exception { + + DefaultPasswordEncryptor encryptor = new DefaultPasswordEncryptor(); + String password = "clear-text-key"; + boolean result; + + result = encryptor.isEncrypted(password); + assertFalse(result); + + password = null; + + result = encryptor.isEncrypted(password); + assertFalse(result); + + password = ""; + + result = encryptor.isEncrypted(password); + assertFalse(result); + + password = DefaultPasswordEncryptor.ENCRYPTED_PREFIX + "encrypted-key"; + + result = encryptor.isEncrypted(password); + assertTrue(result); + + } + + @Test + public void testEncrypt() throws Exception { + + String password = "test"; + String encrypted; + String clear; + String result; + + assertFalse(encryptor.isEncrypted(password)); + + encrypted = encryptor.encrypt(password); + assertTrue(encryptor.isEncrypted(encrypted)); + + result = encryptor.encrypt(encrypted); + assertEquals(encrypted, result); + + clear = encryptor.decrypt(encrypted); + assertEquals(password, clear); + + assertFalse(encryptor.isEncrypted(clear)); + + result = encryptor.decrypt(clear); + assertEquals(clear, result); + + } + +} diff --git a/src/test/java/com/englishtown/stash/hook/MirrorRepositoryHookTest.java b/src/test/java/com/englishtown/stash/hook/MirrorRepositoryHookTest.java index 10d2f30..f98dc16 100644 --- a/src/test/java/com/englishtown/stash/hook/MirrorRepositoryHookTest.java +++ b/src/test/java/com/englishtown/stash/hook/MirrorRepositoryHookTest.java @@ -1,5 +1,7 @@ package com.englishtown.stash.hook; +import com.atlassian.sal.api.pluginsettings.PluginSettings; +import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; import com.atlassian.stash.hook.repository.RepositoryHookContext; import com.atlassian.stash.i18n.I18nService; import com.atlassian.stash.repository.RefChange; @@ -43,6 +45,12 @@ public class MirrorRepositoryHookTest { private GitCommand cmd; @Mock private ScheduledExecutorService executor; + @Mock + private PasswordEncryptor passwordEncryptor; + @Mock + private PluginSettingsFactory pluginSettingsFactory; + @Mock + private PluginSettings pluginSettings; private final String mirrorRepoUrl = "https://stash-mirror.englishtown.com/scm/test/test.git"; private final String username = "test-user"; @@ -69,13 +77,17 @@ public class MirrorRepositoryHookTest { GitScm gitScm = mock(GitScm.class); when(gitScm.getCommandBuilderFactory()).thenReturn(builderFactory); - hook = new MirrorRepositoryHook(gitScm, mock(I18nService.class), executor); + when(pluginSettingsFactory.createSettingsForKey(anyString())).thenReturn(pluginSettings); + + hook = new MirrorRepositoryHook(gitScm, mock(I18nService.class), executor, passwordEncryptor, pluginSettingsFactory); } @Test public void testPostReceive() throws Exception { + when(passwordEncryptor.decrypt(anyString())).thenReturn(password); + Settings settings = mock(Settings.class); when(settings.getString(eq(MirrorRepositoryHook.SETTING_MIRROR_REPO_URL))).thenReturn(mirrorRepoUrl); when(settings.getString(eq(MirrorRepositoryHook.SETTING_USERNAME))).thenReturn(username); @@ -99,7 +111,7 @@ public class MirrorRepositoryHookTest { GitScm gitScm = mock(GitScm.class); when(gitScm.getCommandBuilderFactory()).thenThrow(new RuntimeException("Intentional unit test exception")); - MirrorRepositoryHook hook = new MirrorRepositoryHook(gitScm, mock(I18nService.class), executor); + MirrorRepositoryHook hook = new MirrorRepositoryHook(gitScm, mock(I18nService.class), executor, passwordEncryptor, pluginSettingsFactory); hook.runMirrorCommand(mirrorRepoUrl, username, password, mock(Repository.class)); verify(executor).submit(argumentCaptor.capture()); @@ -200,6 +212,7 @@ public class MirrorRepositoryHookTest { verify(errors).addFieldError(eq(MirrorRepositoryHook.SETTING_MIRROR_REPO_URL), anyString()); verify(errors).addFieldError(anyString(), anyString()); + when(passwordEncryptor.isEncrypted(anyString())).thenReturn(true); errors = mock(SettingsValidationErrors.class); hook.validate(settings, errors, repo); verify(errors, never()).addFormError(anyString());