Repository: QualiSystems/shellfoundry

Issue:
Password field should be encrypted
### Issue details
As a user I want the be able to configure a password via config command (#99) and that shellfoundry will encrypt the password.

* encryption should be very simple
* password should be encrypted when viewing config,yml (both local and global) in text editor




Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index cb2a27c..60dc499 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -2,6 +2,11 @@
 History
 =======
 
+0.1.2 (2016-12-26)
+------------------
+
+* Config command will now encrypt password field
+
 0.1.0 (2016-12-14)
 ------------------
 
diff --git a/setup.cfg b/setup.cfg
index 28bb902..1f55f25 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.1.1
+current_version = 0.1.2
 commit = True
 tag = False
 
diff --git a/shellfoundry/exceptions.py b/shellfoundry/exceptions.py
index 58bc158..95f9fab 100644
--- a/shellfoundry/exceptions.py
+++ b/shellfoundry/exceptions.py
@@ -20,3 +20,8 @@ class NoVersionsHaveBeenFoundException(ShellFoundryBaseException):
 class VersionRequestException(ShellFoundryBaseException):
     def __init__(self, message):
         super(VersionRequestException, self).__init__(message)
+
+class PlatformNameIsEmptyException(ShellFoundryBaseException):
+    def __init__(self, message='Machine name is empty'):
+        super(PlatformNameIsEmptyException, self).__init__(message)
+
diff --git a/shellfoundry/models/install_config.py b/shellfoundry/models/install_config.py
index b33d9d3..28e6d01 100644
--- a/shellfoundry/models/install_config.py
+++ b/shellfoundry/models/install_config.py
@@ -8,7 +8,7 @@ DEFAULT_DOMAIN = 'Global'
 class InstallConfig(object):
     def __init__(self, host, port, username, password, domain):
         self.domain = domain
-        self.password = password
+        self.password = self._decode_password(password)
         self.username = username
         self.port = port
         self.host = host
@@ -30,3 +30,7 @@ class InstallConfig(object):
     def get_default():
         return InstallConfig(DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USERNAME, DEFAULT_PASSWORD, DEFAULT_DOMAIN)
 
+    def _decode_password(self, password):
+        from shellfoundry.utilities.modifiers.configuration.password_modification import PasswordModification
+        pass_mod = PasswordModification()
+        return pass_mod.normalize(password)
diff --git a/shellfoundry/utilities/config/config_context.py b/shellfoundry/utilities/config/config_context.py
index 7ed76d1..1232aeb 100644
--- a/shellfoundry/utilities/config/config_context.py
+++ b/shellfoundry/utilities/config/config_context.py
@@ -1,22 +1,24 @@
 import yaml
 
 from shellfoundry.utilities.config_reader import INSTALL
+from shellfoundry.utilities.modifiers.configuration.aggregated_modifiers import AggregatedModifiers
 
 
 class ConfigContext(object):
     def __init__(self, config_file_path):
         self.config_file_path = config_file_path
+        self.modifier = AggregatedModifiers()
 
     def try_save(self, key, value):
         try:
             with open(self.config_file_path, mode='r+') as stream:
                 data = yaml.load(stream) or {INSTALL: dict()}
-                data[INSTALL][key] = value
+                data[INSTALL][key] = self._modify(key, value)
                 stream.seek(0)
                 stream.truncate()
                 yaml.safe_dump(data, stream=stream, default_flow_style=False)
             return True
-        except:
+        except Exception as e:
             return False
 
     def try_delete(self, key):
@@ -31,3 +33,6 @@ class ConfigContext(object):
         except:
             return False
 
+
+    def _modify(self, key, value):
+        return self.modifier.modify(key, value)
diff --git a/shellfoundry/utilities/modifiers/__init__.py b/shellfoundry/utilities/modifiers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/shellfoundry/utilities/modifiers/configuration/__init__.py b/shellfoundry/utilities/modifiers/configuration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/shellfoundry/utilities/modifiers/configuration/aggregated_modifiers.py b/shellfoundry/utilities/modifiers/configuration/aggregated_modifiers.py
new file mode 100644
index 0000000..cb9c2f8
--- /dev/null
+++ b/shellfoundry/utilities/modifiers/configuration/aggregated_modifiers.py
@@ -0,0 +1,19 @@
+from .password_modification import PasswordModification
+
+DEFAULT_MODIFIER = (lambda x: x) # Default modifier returns the passed value unchanged
+
+class AggregatedModifiers(object):
+    def __init__(self, modifiers=None, default=None):
+        self.modifiers = modifiers or {
+            PasswordModification.HANDLING_KEY: PasswordModification().modify
+        }
+        self.default_modifier = default or AggregatedModifiers._default_modifier
+
+    def modify(self, key, value):
+        if key in self.modifiers:
+            return self.modifiers[key](value)
+        return self.default_modifier(value)
+
+    @staticmethod
+    def _default_modifier(value): # Default modifier returns the passed value unchanged
+        return value
diff --git a/shellfoundry/utilities/modifiers/configuration/password_modification.py b/shellfoundry/utilities/modifiers/configuration/password_modification.py
new file mode 100644
index 0000000..ba19be7
--- /dev/null
+++ b/shellfoundry/utilities/modifiers/configuration/password_modification.py
@@ -0,0 +1,29 @@
+import base64
+import shellfoundry.exceptions as exceptions
+
+class PasswordModification(object):
+    HANDLING_KEY = 'password'
+
+    def modify(self, value):
+        encryption_key = self._get_encryption_key()
+        encoded = self._decode_encode(value, encryption_key)
+        return base64.b64encode(encoded)
+
+    def normalize(self, value):
+        import binascii
+        try:
+            encryption_key = self._get_encryption_key()
+            decoded = self._decode_encode(base64.decodestring(value), encryption_key)
+            return decoded
+        except binascii.Error:
+            return value
+
+    def _get_encryption_key(self):
+        from platform import node
+        machine_name = node()
+        if not machine_name:
+            raise exceptions.PlatformNameIsEmptyException()
+        return machine_name
+
+    def _decode_encode(self, value, key):
+        return ''.join(chr(ord(source) ^ ord(key)) for source, key in zip(value, key * 100))
diff --git a/shellfoundry/version.py b/shellfoundry/version.py
index 748f812..831fd7f 100644
--- a/shellfoundry/version.py
+++ b/shellfoundry/version.py
@@ -1,2 +1,2 @@
-__version__ = '0.1.1'
+__version__ = '0.1.2'
 


Test patch:
diff --git a/tests/test_commands/test_config_command.py b/tests/test_commands/test_config_command.py
index 768cec8..45e2140 100644
--- a/tests/test_commands/test_config_command.py
+++ b/tests/test_commands/test_config_command.py
@@ -1,6 +1,6 @@
 import os
 
-from mock import patch, call
+from mock import patch, call, Mock
 from pyfakefs import fake_filesystem_unittest
 
 from shellfoundry.commands.config_command import ConfigCommandExecutor
@@ -207,3 +207,25 @@ install:
 
         # Assert
         echo_mock.assert_called_with("Local config does not exists")
+
+    @patch('shellfoundry.utilities.config.config_providers.click.get_app_dir')
+    @patch('platform.node', Mock(return_value='machine-name-here'))
+    def test_set_password_config_password_should_appear_encrypted(self, get_app_dir_mock):
+        # Arrange
+        self.fs.CreateFile('/quali/shellfoundry/global_config.yml', contents="""
+install:
+  key: value
+""")
+        get_app_dir_mock.return_value = '/quali/shellfoundry'
+
+        # Act
+        ConfigCommandExecutor(True).config(('password', 'admin'))
+
+        # Assert
+        desired_result = """install:
+  key: value
+  password: DAUOAQc=
+"""
+        file_content = self.fs.GetObject('/quali/shellfoundry/global_config.yml').contents
+        self.assertTrue(file_content == desired_result, 'Expected: {}{}Actual: {}'
+                        .format(desired_result, os.linesep, file_content))
diff --git a/tests/test_models/test_install_config.py b/tests/test_models/test_install_config.py
index 9560fea..c6ddbe1 100644
--- a/tests/test_models/test_install_config.py
+++ b/tests/test_models/test_install_config.py
@@ -1,4 +1,6 @@
 import unittest
+
+from mock import Mock, patch
 from shellfoundry.models.install_config import InstallConfig
 
 
@@ -14,3 +16,14 @@ class TestInstallConfig(unittest.TestCase):
         config2 = InstallConfig('remote', 1, 'U', 'P', 'Local')
 
         self.assertNotEqual(config1, config2)
+
+    @patch('platform.node', Mock(return_value='machine-name-here'))
+    def test_encrypted_password_field_becomes_decrypted(self):
+        config = InstallConfig('localhost', 9000, 'YOUR_USERNAME', 'DAUOAQc=', 'Global')
+        self.assertEqual("admin", config.password)
+
+    @patch('platform.node', Mock(return_value='machine-name-here'))
+    def test_non_encrypted_password_field_stays_regular(self):
+        config = InstallConfig('localhost', 9000, 'YOUR_USERNAME', 'admin', 'Global')
+        self.assertEqual("admin", config.password)
+
diff --git a/tests/test_utilities/modifiers/__init__.py b/tests/test_utilities/modifiers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_utilities/modifiers/configuration/__init__.py b/tests/test_utilities/modifiers/configuration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_utilities/modifiers/configuration/test_password_modification.py b/tests/test_utilities/modifiers/configuration/test_password_modification.py
new file mode 100644
index 0000000..9b54cb2
--- /dev/null
+++ b/tests/test_utilities/modifiers/configuration/test_password_modification.py
@@ -0,0 +1,13 @@
+import unittest
+
+from mock import patch, Mock
+from shellfoundry.utilities.modifiers.configuration.password_modification import  PasswordModification
+from shellfoundry.exceptions import PlatformNameIsEmptyException
+
+class TestPasswordModification(unittest.TestCase):
+    @patch('platform.node', Mock(return_value=''))
+    def test_platform_node_returns_empty_error_is_raise(self):
+        pass_mod = PasswordModification()
+        with self.assertRaises(PlatformNameIsEmptyException):
+            pass_mod.modify('some_value')
+