From 6f6897c1320b032b4589121966fe7edeccf929f7 Mon Sep 17 00:00:00 2001 From: Stan Lagun Date: Tue, 2 Jan 2018 20:05:44 -0800 Subject: [PATCH] Murano-engine side implementation of agent message signing Change-Id: I1a23d185ac19f10c98d66f29a6930dfd17793954 Partial-Blueprint: message-signing --- .../resources/LinuxMuranoInstance.yaml | 1 + .../Classes/resources/WindowsInstance.yaml | 1 + meta/io.murano/Resources/Agent-v1.template | 4 +- meta/io.murano/Resources/Agent-v2.template | 3 ++ murano/common/config.py | 3 ++ murano/common/messaging/mqclient.py | 12 +++-- murano/engine/system/agent.py | 47 ++++++++++++++++++- .../unit/common/messaging/test_mqclient.py | 18 ++++++- murano/tests/unit/engine/system/test_agent.py | 31 +++++++++--- .../message-signing-07b09e541c2d94d6.yaml | 7 +++ requirements.txt | 1 + 11 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 releasenotes/notes/message-signing-07b09e541c2d94d6.yaml diff --git a/meta/io.murano/Classes/resources/LinuxMuranoInstance.yaml b/meta/io.murano/Classes/resources/LinuxMuranoInstance.yaml index ccd91749c..0c89af0d5 100644 --- a/meta/io.murano/Classes/resources/LinuxMuranoInstance.yaml +++ b/meta/io.murano/Classes/resources/LinuxMuranoInstance.yaml @@ -79,6 +79,7 @@ Methods: "%RABBITMQ_INSECURE%": str($rabbitMqParams.insecure).toLower() "%RABBITMQ_INPUT_QUEUE%": $.agent.queueName() "%RESULT_QUEUE%": $region.agentListener.queueName() + "%SIGNING_KEY%": $.agent.signingKey('\t') - $scriptReplacements: "%AGENT_CONFIG_BASE64%": base64encode($configFile.replace($configReplacements)) "%INTERNAL_HOSTNAME%": $.name diff --git a/meta/io.murano/Classes/resources/WindowsInstance.yaml b/meta/io.murano/Classes/resources/WindowsInstance.yaml index ca10f2f14..15dd20317 100644 --- a/meta/io.murano/Classes/resources/WindowsInstance.yaml +++ b/meta/io.murano/Classes/resources/WindowsInstance.yaml @@ -50,6 +50,7 @@ Methods: "%RABBITMQ_SSL%": str($rabbitMqParams.ssl).toLower() "%RABBITMQ_INPUT_QUEUE%": $.agent.queueName() "%RESULT_QUEUE%": $region.agentListener.queueName() + "%SIGNING_KEY%": $.agent.signingKey('') - $scriptReplacements: "%AGENT_CONFIG_BASE64%": base64encode($configFile.replace($configReplacements)) "%INTERNAL_HOSTNAME%": $.name diff --git a/meta/io.murano/Resources/Agent-v1.template b/meta/io.murano/Resources/Agent-v1.template index 20d156f28..0e151dd5e 100644 --- a/meta/io.murano/Resources/Agent-v1.template +++ b/meta/io.murano/Resources/Agent-v1.template @@ -4,7 +4,7 @@
- + @@ -32,5 +32,7 @@ + + diff --git a/meta/io.murano/Resources/Agent-v2.template b/meta/io.murano/Resources/Agent-v2.template index a06a4f578..747e99022 100644 --- a/meta/io.murano/Resources/Agent-v2.template +++ b/meta/io.murano/Resources/Agent-v2.template @@ -5,6 +5,9 @@ log_file = /var/log/murano-agent.log storage=/var/murano/plans +engine_key = +%SIGNING_KEY% + [rabbitmq] # Input queue name diff --git a/murano/common/config.py b/murano/common/config.py index 24c019706..e9f04c46a 100644 --- a/murano/common/config.py +++ b/murano/common/config.py @@ -220,6 +220,9 @@ engine_opts = [ 'for legacy behavior using murano-api) or glance ' '(stands for glance-glare artifact service)'), deprecated_group='packages_opts'), + + cfg.StrOpt('signing_key', default='~/.ssh/id_rsa', + help=_('Path to RSA key for agent message signing')), ] # TODO(sjmc7): move into engine opts? diff --git a/murano/common/messaging/mqclient.py b/murano/common/messaging/mqclient.py index cd7afd495..33ccdba85 100644 --- a/murano/common/messaging/mqclient.py +++ b/murano/common/messaging/mqclient.py @@ -102,16 +102,22 @@ class MqClient(object): bound_queue = queue(self._connection) bound_queue.declare() - def send(self, message, key, exchange=''): + def send(self, message, key, exchange='', signing_func=None): if not self._connected: raise RuntimeError('Not connected to RabbitMQ') producer = kombu.Producer(self._connection) + data = jsonutils.dumps(message.body) + headers = None + if signing_func: + headers = {'signature': signing_func(data)} + producer.publish( exchange=str(exchange), routing_key=str(key), - body=jsonutils.dumps(message.body), - message_id=str(message.id) + body=data, + message_id=str(message.id), + headers=headers ) def open(self, queue, prefetch_count=1): diff --git a/murano/engine/system/agent.py b/murano/engine/system/agent.py index 83a787e68..60f3dc066 100644 --- a/murano/engine/system/agent.py +++ b/murano/engine/system/agent.py @@ -16,8 +16,14 @@ import copy import datetime import os +import os.path +import time import uuid +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization import eventlet.event from oslo_config import cfg from oslo_log import log as logging @@ -49,12 +55,33 @@ class Agent(object): self._enabled = not CONF.engine.disable_murano_agent env = host.find_owner('io.murano.Environment') self._queue = str('e%s-h%s' % (env.id, host.id)).lower() + self._signing_key = None + if CONF.engine.signing_key: + key_path = os.path.expanduser(CONF.engine.signing_key) + if not os.path.exists(key_path): + LOG.warn("Key file %s does not exist. " + "Message signing is disabled") + else: + with open(key_path, "rb") as key_file: + key_data = key_file.read() + self._signing_key = serialization.load_pem_private_key( + key_data, password=None, backend=default_backend()) + self._last_stamp = 0 self._initialized = False @property def enabled(self): return self._enabled + @specs.parameter('line_prefix', specs.yaqltypes.String()) + def signing_key(self, line_prefix=''): + if not self._signing_key: + return "" + key = self._signing_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.PKCS1) + return line_prefix + line_prefix.join(key.splitlines(True)) + def _initialize(self): if self._initialized: return @@ -96,8 +123,7 @@ class Agent(object): msg = self._prepare_message(template, msg_id) with common.create_rmq_client(region) as client: - client.send(message=msg, key=self._queue) - + client.send(message=msg, key=self._queue, signing_func=self._sign) if wait_results: try: with eventlet.Timeout(timeout): @@ -160,6 +186,13 @@ class Agent(object): template = {'Body': 'return', 'FormatVersion': '2.0.0', 'Scripts': {}} self.call_raw(template, timeout) + def _sign(self, msg): + if not self._signing_key: + return None + return self._signing_key.sign( + (self._queue + msg).encode('utf-8'), + padding.PKCS1v15(), hashes.SHA256()) + def _process_v1_result(self, result): if result['IsException']: raise AgentException(dict(self._get_exception_info( @@ -216,6 +249,13 @@ class Agent(object): else: return self._build_v2_execution_plan(template, resources) + def _generate_stamp(self): + stamp = int(time.time() * 10000) + if stamp <= self._last_stamp: + stamp = self._last_stamp + 1 + self._last_stamp = stamp + return stamp + def _build_v1_execution_plan(self, template, resources): scripts_folder = 'scripts' script_files = template.get('Scripts', []) @@ -226,12 +266,15 @@ class Agent(object): resources.string(script_path, binary=True), encoding='latin1')) template['Scripts'] = scripts + template['Stamp'] = self._generate_stamp() return template def _build_v2_execution_plan(self, template, resources): scripts_folder = 'scripts' plan_id = uuid.uuid4().hex template['ID'] = plan_id + template['Stamp'] = self._generate_stamp() + if 'Action' not in template: template['Action'] = 'Execute' if 'Files' not in template: diff --git a/murano/tests/unit/common/messaging/test_mqclient.py b/murano/tests/unit/common/messaging/test_mqclient.py index 37c06afa6..f24dec1db 100644 --- a/murano/tests/unit/common/messaging/test_mqclient.py +++ b/murano/tests/unit/common/messaging/test_mqclient.py @@ -209,7 +209,23 @@ class MQClientTest(base.MuranoTestCase): self.ssl_client._connection) mock_kombu.Producer().publish.assert_called_once_with( exchange='test_exchange', routing_key='test_key', - body=jsonutils.dumps('test_message'), message_id='3') + body=jsonutils.dumps('test_message'), message_id='3', + headers=None) + + @mock.patch('murano.common.messaging.mqclient.kombu') + def test_send_signed(self, mock_kombu): + mock_message = mock.MagicMock(body='test_message', id=3) + + signer = lambda msg: "SIGNATURE" + self.ssl_client.connect() + self.ssl_client.send(mock_message, 'test_key', 'test_exchange', signer) + + mock_kombu.Producer.assert_called_once_with( + self.ssl_client._connection) + mock_kombu.Producer().publish.assert_called_once_with( + exchange='test_exchange', routing_key='test_key', + body=jsonutils.dumps('test_message'), message_id='3', + headers={'signature': 'SIGNATURE'}) def test_send_except_runtime_error(self): with self.assertRaisesRegex(RuntimeError, diff --git a/murano/tests/unit/engine/system/test_agent.py b/murano/tests/unit/engine/system/test_agent.py index 00033aa85..57b9660dd 100644 --- a/murano/tests/unit/engine/system/test_agent.py +++ b/murano/tests/unit/engine/system/test_agent.py @@ -94,13 +94,15 @@ class TestAgent(base.MuranoTestCase): self.assertRaises(exceptions.PolicyViolationException, self.agent.send_raw, {}) + @mock.patch('murano.engine.system.agent.Agent._sign') @mock.patch('murano.common.messaging.mqclient.kombu') - def test_send(self, mock_kombu): + def test_send(self, mock_kombu, mock_sign): template = yamllib.load( self._read('template_with_files.template'), Loader=self.yaml_loader) self.agent._queue = 'test_queue' + mock_sign.return_value = 'SIGNATURE' plan = self.agent.build_execution_plan(template, self.resources) with mock.patch.object(self.agent, 'build_execution_plan', return_value=plan): @@ -111,7 +113,9 @@ class TestAgent(base.MuranoTestCase): exchange='', routing_key='test_queue', body=json.dumps(plan), - message_id=plan['ID']) + message_id=plan['ID'], + headers={'signature': 'SIGNATURE'} + ) @mock.patch('murano.engine.system.agent.eventlet.event.Event') @mock.patch('murano.common.messaging.mqclient.kombu') @@ -129,9 +133,10 @@ class TestAgent(base.MuranoTestCase): self.assertFalse(self.agent.is_ready(1)) + @mock.patch('murano.engine.system.agent.Agent._sign') @mock.patch('murano.engine.system.agent.eventlet.event.Event') @mock.patch('murano.common.messaging.mqclient.kombu') - def test_call_with_v1_result(self, mock_kombu, mock_event): + def test_call_with_v1_result(self, mock_kombu, mock_event, mock_sign): template = yamllib.load( self._read('template_with_files.template'), Loader=self.yaml_loader) @@ -149,6 +154,7 @@ class TestAgent(base.MuranoTestCase): mock_event().wait.side_effect = None mock_event().wait.return_value = test_v1_result + mock_sign.return_value = 'SIGNATURE' self.agent._queue = 'test_queue' plan = self.agent.build_execution_plan(template, self.resources) @@ -164,11 +170,14 @@ class TestAgent(base.MuranoTestCase): exchange='', routing_key='test_queue', body=json.dumps(plan), - message_id=plan['ID']) + message_id=plan['ID'], + headers={'signature': 'SIGNATURE'} + ) + @mock.patch('murano.engine.system.agent.Agent._sign') @mock.patch('murano.engine.system.agent.eventlet.event.Event') @mock.patch('murano.common.messaging.mqclient.kombu') - def test_call_with_v2_result(self, mock_kombu, mock_event): + def test_call_with_v2_result(self, mock_kombu, mock_event, mock_sign): template = yamllib.load( self._read('template_with_files.template'), Loader=self.yaml_loader) @@ -179,6 +188,7 @@ class TestAgent(base.MuranoTestCase): mock_event().wait.side_effect = None mock_event().wait.return_value = v2_result + mock_sign.return_value = 'SIGNATURE' self.agent._queue = 'test_queue' plan = self.agent.build_execution_plan(template, self.resources) @@ -194,7 +204,9 @@ class TestAgent(base.MuranoTestCase): exchange='', routing_key='test_queue', body=json.dumps(plan), - message_id=plan['ID']) + message_id=plan['ID'], + headers={'signature': 'SIGNATURE'} + ) @mock.patch('murano.engine.system.agent.eventlet.event.Event') @mock.patch('murano.common.messaging.mqclient.kombu') @@ -357,6 +369,8 @@ class TestExecutionPlan(base.MuranoTestCase): self.resources.string.return_value = 'text' self.uuids = ['ID1', 'ID2', 'ID3', 'ID4'] self.mock_uuid = self._stub_uuid(self.uuids) + time_mock = mock.patch('time.time').start() + time_mock.return_value = 2 self.addCleanup(mock.patch.stopall) def _read(self, path): @@ -430,6 +444,7 @@ class TestExecutionPlan(base.MuranoTestCase): }, 'FormatVersion': '2.0.0', 'ID': self.uuids[0], + 'Stamp': 20000, 'Name': 'Deploy Tomcat', 'Parameters': { 'appName': '$appName' @@ -470,6 +485,7 @@ class TestExecutionPlan(base.MuranoTestCase): }, 'FormatVersion': '2.0.0', 'ID': self.uuids[0], + 'Stamp': 20000, 'Name': 'Deploy Tomcat', 'Parameters': { 'appName': '$appName' @@ -504,6 +520,7 @@ class TestExecutionPlan(base.MuranoTestCase): }, 'FormatVersion': '2.0.0', 'ID': self.uuids[0], + 'Stamp': 20000, 'Name': 'Deploy Tomcat', 'Parameters': { 'appName': '$appName' @@ -542,6 +559,7 @@ class TestExecutionPlan(base.MuranoTestCase): }, 'FormatVersion': '2.0.0', 'ID': self.uuids[0], + 'Stamp': 20000, 'Name': 'Deploy Chef', 'Parameters': { 'appName': '$appName' @@ -587,6 +605,7 @@ class TestExecutionPlan(base.MuranoTestCase): }, 'FormatVersion': '2.0.0', 'ID': self.uuids[0], + 'Stamp': 20000, 'Name': 'Deploy Telnet', 'Parameters': { 'appName': '$appName' diff --git a/releasenotes/notes/message-signing-07b09e541c2d94d6.yaml b/releasenotes/notes/message-signing-07b09e541c2d94d6.yaml new file mode 100644 index 000000000..e5ae8cbee --- /dev/null +++ b/releasenotes/notes/message-signing-07b09e541c2d94d6.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Murano engine can be configured to sign all the RabbitMQ messages sent + to the agents. When the RSA key is provided, engine will provide agents + with its public part and sign all the messages sent. Agents then will + ignore any command that was not sent by the engine. diff --git a/requirements.txt b/requirements.txt index 5f43ca077..76f33c349 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ keystonemiddleware>=4.17.0 # Apache-2.0 testtools>=2.2.0 # MIT yaql>=1.1.3 # Apache 2.0 License debtcollector>=1.2.0 # Apache-2.0 +cryptography>=1.9,!=2.0 # BSD/Apache-2.0 # For paste.util.template used in keystone.common.template Paste>=2.0.2 # MIT