From b117c0d4118b57c0fda46dd05008fc00c6f293f1 Mon Sep 17 00:00:00 2001 From: Mitch Garnaat Date: Sat, 27 Jul 2013 12:27:58 -0700 Subject: [PATCH] Adding a --priv-launch-key parameter to get-password-data which allows the encrypted password to be decrypted using the supplied SSH private key file. --- awscli/clidriver.py | 5 + awscli/customizations/ec2decryptpassword.py | 150 ++++++++++++++++++++ awscli/handlers.py | 9 ++ requirements.txt | 1 + setup.py | 5 +- tests/unit/ec2/test_get_password_data.py | 62 ++++++++ tests/unit/ec2/testcli.pem | 23 +++ tests/unit/test_clidriver.py | 1 + 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 awscli/customizations/ec2decryptpassword.py create mode 100644 tests/unit/ec2/test_get_password_data.py create mode 100644 tests/unit/ec2/testcli.pem diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 9c52ae1448c2..7377d71408c1 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -701,6 +701,11 @@ def __call__(self, args, parsed_globals): if remaining: raise UnknownArgumentError( "Unknown options: %s" % ', '.join(remaining)) + service_name = self._service_object.endpoint_prefix + operation_name = self._operation_object.name + self._emit('operation-args-parsed.%s.%s' % (service_name, + operation_name), + operation=self._operation_object, parsed_args=parsed_args) call_parameters = self._build_call_parameters(parsed_args, self.arg_table) return self._operation_caller.invoke( diff --git a/awscli/customizations/ec2decryptpassword.py b/awscli/customizations/ec2decryptpassword.py new file mode 100644 index 000000000000..b01bbc1d414d --- /dev/null +++ b/awscli/customizations/ec2decryptpassword.py @@ -0,0 +1,150 @@ +# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import logging +import os +import base64 +import rsa +import six + +from awscli.clidriver import BaseCLIArgument +from botocore.parameters import StringParameter + +logger = logging.getLogger(__name__) + + +HELP = """

The file that contains the private key used to launch +the instance (e.g. windows-keypair.pem). If this is supplied, the +password data sent from EC2 will be decrypted before display.

""" + + +# This is a bit kludgy. +# We need a way to pass some state between the event handlers. +# One event handler determines if a path to the private key +# file was specified and, if so, validates the path. +# The other event handler attempts to decrypt the password data +# if a private key path was specified. +# I'm using the module-level globalvar to maintain that shared +# state. In the context of a CLI, I think that's okay. + +_key_path = None + +def _set_key_path(path): + global _key_path + _key_path = path + +def _get_key_path(): + global _key_path + return _key_path + + +def decrypt_password_data(event_name, shape, value, **kwargs): + """ + This handler gets called after the PasswordData element of the + response has been parsed. It checks to see if a private launch + key was specified on the command. If it was, it tries to use + that private key to decrypt the password data and return it. + """ + key_path = _get_key_path() + if key_path: + logger.debug('decrypt_password_data: %s', key_path) + try: + private_key_file = open(key_path) + private_key_contents = private_key_file.read() + private_key_file.close() + private_key = rsa.PrivateKey.load_pkcs1(six.b(private_key_contents)) + value = base64.b64decode(value) + value = rsa.decrypt(value, private_key) + except: + # TODO + # Should we raise an exception or just return the + # unencrypted data? Or maybe just print a message? + logger.debug('Unable to decrypt PasswordData', exc_info=True) + msg = ('Unable to decrypt password data using ' + 'provided private key file.') + raise ValueError(msg) + return value + + +def ec2_add_priv_launch_key(argument_table, operation, **kwargs): + """ + This handler gets called after the argument table for the + operation has been created. It's job is to add the + ``priv-launch-key`` parameter. + """ + argument_table['priv-launch-key'] = LaunchKeyArgument(operation, + 'priv-launch-key') + + +def ec2_process_priv_launch_key(operation, parsed_args, **kwargs): + """ + This handler gets called after the command line arguments to + the ``get-password-data`` command have been parsed. It is + passed the ``Operation`` object and the ``Namespace`` containing + the parsed args. + + It needs to check to see if ``priv-launch-key`` was supplied + by the user. If it was, it checks to make sure the path provided + points to a real file and, if so, stores the path in the module + global var for access later by the decrypt method. + """ + if parsed_args.priv_launch_key: + path = os.path.expandvars(parsed_args.priv_launch_key) + path = os.path.expanduser(path) + logger.debug(path) + if os.path.isfile(path): + _set_key_path(path) + else: + msg = ('priv-launch-key should be a path to the ' + 'local SSH private key file used to launch ' + 'the instance.') + raise ValueError(msg) + + +class LaunchKeyArgument(BaseCLIArgument): + + def __init__(self, operation, name): + param = StringParameter(operation, + name='priv_launch_key', + type='string') + super(LaunchKeyArgument, self).__init__( + name, argument_object=param) + self._operation = operation + self._name = name + + @property + def cli_name(self): + return '--' + self._name + + @property + def cli_type_name(self): + return 'string' + + @property + def required(self): + return False + + @property + def documentation(self): + return HELP + + def add_to_parser(self, parser, cli_name=None): + parser.add_argument(self.cli_name, metavar=self.py_name, + help='Number of instances to launch') + + def add_to_params(self, parameters, value): + """ + Since the extra ``priv-launch-key`` parameter is local and + doesn't need to be sent to the service, we don't have to do + anything here. + """ + pass diff --git a/awscli/handlers.py b/awscli/handlers.py index fdcbccb908be..bab5b3e8e7e5 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -22,6 +22,9 @@ from awscli.customizations.removals import register_removals from awscli.customizations.ec2addcount import ec2_add_count from awscli.customizations.paginate import unify_paging_params +from awscli.customizations.ec2decryptpassword import ec2_add_priv_launch_key +from awscli.customizations.ec2decryptpassword import ec2_process_priv_launch_key +from awscli.customizations.ec2decryptpassword import decrypt_password_data def awscli_initialize(event_handlers): @@ -44,4 +47,10 @@ def awscli_initialize(event_handlers): ec2_add_count) event_handlers.register('building-argument-table', unify_paging_params) + event_handlers.register('building-argument-table.ec2.GetPasswordData', + ec2_add_priv_launch_key) + event_handlers.register('operation-args-parsed.ec2.GetPasswordData', + ec2_process_priv_launch_key) + event_handlers.register('after-parsed.ec2.GetPasswordData.String.PasswordData', + decrypt_password_data) register_removals(event_handlers) diff --git a/requirements.txt b/requirements.txt index f6407e06f594..6b6a74447dcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ nose==1.3.0 colorama==0.2.5 mock==1.0.1 httpretty==0.6.1 +rsa==3.1.1 diff --git a/setup.py b/setup.py index 33e31ed3d386..730be28f9631 100644 --- a/setup.py +++ b/setup.py @@ -5,9 +5,7 @@ """ import os -import sys import awscli -import glob try: from setuptools import setup @@ -37,7 +35,8 @@ def get_data_files(): 'six>=1.1.0', 'colorama==0.2.5', 'argparse>=1.1', - 'docutils>=0.10'] + 'docutils>=0.10', + 'rsa==3.1.1'] setup( diff --git a/tests/unit/ec2/test_get_password_data.py b/tests/unit/ec2/test_get_password_data.py new file mode 100644 index 000000000000..496b5ab05729 --- /dev/null +++ b/tests/unit/ec2/test_get_password_data.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# Copyright 2012-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +from tests.unit import BaseAWSCommandParamsTest +import os +from awscli.clidriver import create_clidriver +from awscli.customizations.ec2decryptpassword import _get_key_path +from botocore.response import XmlResponse + +GET_PASSWORD_DATA_RESPONSE = """ + + 000000000000 + i-12345678 + 2013-07-27T18:29:23.000Z + +GWDnuoj/7pbMQkg125E8oGMUVCI+r98sGbFFl8SX+dEYxMZzz+byYwwjvyg8iSGKaLuLTIWatWopVu5cMWDKH65U4YFL2g3LqyajBrCFnuSE1piTeS/rPQpoSvBN5FGj9HWqNrglWAJgh9OZNSGgpEojBenL/0rwSpDWL7f/f52M5doYA6q+v0ygEoi1Wq6hcmrBfyA4seW1RlKgnUru5Y9oc1hFHi53E3b1EkjGqCsCemVUwumBj8uwCLJRaMcqrCxK1smtAsiSqk0Jk9jpN2vcQgnMPypEdmEEXyWHwq55fjy6ch+sqYcwumIL5QcFW2JQ5+XBEoFhC66gOsAXow== + +""" + + +class TestGetPasswordData(BaseAWSCommandParamsTest): + + prefix = 'ec2 get-password-data' + + def test_no_priv_launch_key(self): + args = ' --instance-id i-12345678' + cmdline = self.prefix + args + result = {'InstanceId': 'i-12345678'} + self.assert_params_for_cmd(cmdline, result) + + def test_nonexistent_priv_launch_key(self): + args = ' --instance-id i-12345678 --priv-launch-key foo.pem' + cmdline = self.prefix + args + result = {} + self.assert_params_for_cmd(cmdline, result, expected_rc=255) + + def test_priv_launch_key(self): + driver = create_clidriver() + key_path = os.path.join(os.path.dirname(__file__), + 'testcli.pem') + args = ' --instance-id i-12345678 --priv-launch-key %s' % key_path + cmdline = self.prefix + args + cmdlist = cmdline.split() + rc = driver.main(cmdlist) + self.assertEqual(rc, 0) + self.assertEqual(key_path, _get_key_path()) + service = driver.session.get_service('ec2') + operation = service.get_operation('GetPasswordData') + r = XmlResponse(driver.session, operation) + r.parse(GET_PASSWORD_DATA_RESPONSE, 'utf-8') + response_data = r.get_value() + self.assertEqual(response_data['PasswordData'], '=mG8.r$o-s') diff --git a/tests/unit/ec2/testcli.pem b/tests/unit/ec2/testcli.pem new file mode 100644 index 000000000000..c91c1aeb23f2 --- /dev/null +++ b/tests/unit/ec2/testcli.pem @@ -0,0 +1,23 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAiaf6VsJ5w7emzh+6ffFj31ropPeW2N8wnNtfkItp4gmCdCpTQA4Kce/tzk5V +VPTaPyanYXcdwib4KXqf77dSACxpqtel7jwotdLLNU9uUm9pIFsVh0qnLJxXdgJgujsY/HBq4BRE +l7W1uA0e7QD1DH3QImELEXmnwO9ym/CiuwpUYGYrLLwIaezGjv/XUsn/KMzR2cQsseQu+jiBWXhA ++4plnC4aNOWCXym/VlTgRDaGL8FijEzfiL+k4ZrG6/qakAt1xDkB8xGY4yOCexnL80Pi31ybT7QG +odhuIkUr1CCgakvBpY1y8X8/yXmwZZtzl/qS47cIz9OUMjeFsfBBZwIDAQABAoIBAEXNN9PmqXfl +GGBNFnPmg44uuulr4sH16uCfHMZe60IDMHNXQv+oHwPHdf63Ge4KeuCq6RUzIZPhztS5qYAUpTAR +VUOcNjenqb0JNqHBtV93vwb5KOGBqWOlo3PjoMjOTs0y8/7MSDvlmE/L13K2mYvMAE5uhv5FghsD +UEpiqyHMSl4gGqOxz1SbOLvmSdlmlLHW9qjrT+oEoqZX/nfQyJHk0zoZlfTJdnBL5R1GtTP2mI/E +GCy4z7qllmGJR4x24GZxNAggr3mWvmkaVxivO2+3FN1TtwGJo/Wj5ncZLv889TXzj2V8HOcoZ8L/ +GsZGxdJ5rWrxRLQySePSUKze+gECgYEA27n4p6uJLF8Ra25nQmgs2/VpzM1K0fd1tX3yKCYFGwbl +45Lzi7a672GQryAmSVWSfCgf9uwZmVWcPW1gZT2pBqP1+8EF1T2L6yPnGR1psF9jMmWGJogFlM4o +P++Ym3SHT4bd0we1iUnEH6JS65BEmopVhM8/7ZP50WHibDddoRcCgYEAoGGSLRLpvwczgOlirNSM +RCsJ9uAWUBZYMO1XMoAet0CWat9YwOqiiavcsD+0bNH0bZISQrLtaoiDSdFpRYjcgpBeu4MaGGqJ +EXYUGxNwmY1TZ3ml+9Oh1I84p0XIALeCueEFH9lJaXHzdhjNDn+KxKUg7UIz72+v3n/AyhvqdDEC +gYEAsyUwR7xCreug7z9nbywyju/LYBBtFT22OdBC9FrzRLLeEirI6LuGNBAO/8mtjZL4SMQKM68R +vAOhzC92LXUVb3WU47rff5mbj46JJ9/kQMm0ve0qcBXsvwNKq740ZWKfw8ZI63rYluOOxN/6zVal +qH5q9Upoa9J/FyjAi8ykSOcCgYBJknjsFHEGINePm4CYqChwXQ4FImcZ9iYey8HkeMGebxKRlEOy +u/A0F5L1h0PNZ8MpQIj/7/TZmiYgBuCz9USy4GeUvV+LM9QNHo26ngBZcGuCXFu4Wi0yxUDH+0r0 +iTp+6qrfIV578LouwtHOhNOzwcyJCoWooSOcfh6CmKvFAQKBgH+6tVQi9Tj4Ig15HueURVVeiPVn +L9MPiR4unUsPz6OevoNsRaDkj7XDFBuMiFPGoXoyL9SedSSFw+oRxcPi4YMMvsZNC0yxOnj2Qe8z +V1Xgvb+rlN6NEIlPcscxE2F8gu1zIan/lXLMEXMr9hAAaGqS/JSALzt6XcoHwzEGnTrH +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index 7dd18bf4e073..9167c888f045 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -205,6 +205,7 @@ def test_expected_events_are_emitted_in_order(self): 'top-level-args-parsed', 'building-command-table.s3', 'building-argument-table.s3.ListObjects', + 'operation-args-parsed.s3.ListObjects', 'process-cli-arg.s3.list-objects', ])