Skip to content

Commit

Permalink
Add support for smart cards / soft cards
Browse files Browse the repository at this point in the history
This should work with pkcs11 and other ssl engines.

You will need to setup those new options in your configuration profile:
- ssl_engine_id (pkcs11, ...)
- ssl_engine_path (path to pkcs11 lib, ...)
- ssl_module_path (path to opensc, softhsm, ...)

And setup:
- x509_client_cert
- x509_client_key

Check `config.smartcard.example.json` example file.

ref #66

Signed-off-by: Jérôme Jutteau <[email protected]>
  • Loading branch information
jerome-jutteau committed Aug 3, 2021
1 parent ef236b5 commit 87644df
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 20 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ Example:
```
osc-cli icu ReadVolumes --authentication-method=ephemeral --interactive
```
## Client certificates

Some users may want to setup their account in order to limit API access from specific IP or specific CA. Check [Api Access Rules](https://wiki.outscale.net/display/EN/About+API+Access+Rules) for more details. If you choose to use a client certificate with osc-cli, you can setup this by either using local cert and key files or by using a smart card.

For client certificates as files, you will have to configure `x509_client_cert` and `x509_client_key` options in your profile. For smart card usage, you will also have to fill `ssl_engine_id` and you optionally specify `ssl_engine_path` and `ssl_module_path` options.

Check [`config.cert.example.json`](config.cert.example.json) and [`config.smartcard.example.json`](config.smartcard.example.json).

Note that client certificate configuration can be combined with any authentication method (see above).

## Contributing
OSC-CLI is an **open source software** licensed under **BSD-3-Clause.**
Expand Down
22 changes: 22 additions & 0 deletions config.smartcard.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"default": {
"access_key": "DS564D65S4DEXAMPLE",
"secret_key": "8D4G684SD65S4DD5F46D54F6D54F6D5FEXAMPLE",
"x509_client_cert": "pkcs11:token=token0;type=cert;object=osc.cert",
"x509_client_key": "pkcs11:token=token0;type=private;object=osc.key;pin-value=1234",
"ssl_engine_id": "pkcs11",
"ssl_engine_path": "/usr/lib/x86_64-linux-gnu/engines-1.1/pkcs11.so",
"ssl_module_path": "/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
"region": "eu-west-2",
"endpoints": {
"api": "api.eu-west-2.outscale.com/api/v1",
"fcu": "fcu.eu-west-2.outscale.com",
"lbu": "lbu.eu-west-2.outscale.com",
"eim": "eim.eu-west-2.outscale.com",
"icu": "icu.eu-west-2.outscale.com",
"directlink": "directlink.eu-west-2.outscale.com",
"oos": "oos.eu-west-2.outscale.com"
}
}
}

137 changes: 120 additions & 17 deletions osc_sdk/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import os
import stat
import getpass
import M2Crypto
import urllib3

CANONICAL_URI = '/'
CONFIGURATION_FILE = 'config.json'
Expand Down Expand Up @@ -150,9 +152,9 @@ def setup_profile_options(self, conf_path, profile):
self.profile_name = profile
self.access_key = conf.get('access_key')
self.secret_key = conf.get('secret_key')
x509_client_cert = conf.get('x509_client_cert')
x509_client_key = conf.get('x509_client_key')
self.client_certificate = (x509_client_cert, x509_client_key)
self.x509_client_cert = conf.get('x509_client_cert')
self.x509_client_key = conf.get('x509_client_key')
self.client_certificate = (self.x509_client_cert, self.x509_client_key)
self.protocol = conf.get('protocol', 'https')
self.method = conf.get('method', DEFAULT_METHOD)
if isinstance(self.method, str):
Expand All @@ -168,8 +170,14 @@ def setup_profile_options(self, conf_path, profile):
# Additionnal specific osc-cli options
self.ssl_verify = conf.get('ssl_verify', SSL_VERIFY)
self.version = conf.get('version', DEFAULT_VERSION)
self.ssl_engine_id = conf.get('ssl_engine_id')
self.ssl_engine_path = conf.get('ssl_engine_path')
self.ssl_module_path = conf.get('ssl_module_path')

def setup_profile_options_deprecated(self, conf_path, profile):
self.ssl_engine_id = None
self.ssl_engine_path = None
self.ssl_module_path = None
conf = json.loads(conf_path.read_text())
try:
conf = conf[profile]
Expand Down Expand Up @@ -256,6 +264,9 @@ def check_options(self):
abort('Endpoint is not configured')
if self.method not in METHODS_SUPPORTED:
abort('Method {} is not supported'.format(self.method))
if self.ssl_engine_id:
if self.protocol != 'https':
abort('Protocol must be "https" with ssl_engine_id')

def init_ephemeral_auth(self):
if self.authentication_method != 'ephemeral':
Expand Down Expand Up @@ -412,7 +423,7 @@ def get_password_params(self):
'Password': self.password
}

def get_response(self, request):
def get_response(self, request, raw_content):
raise NotImplementedError

def get_parameters(self, data, prefix=''):
Expand Down Expand Up @@ -495,8 +506,12 @@ def make_request(self, action, *args, **kwargs):
signed_headers,
)})

request = requests.Session()
if self.ssl_engine_id:
adapter = HttpsAdapter(self.ssl_engine_id, self.ssl_engine_path, self.ssl_module_path)
request.mount("https://", adapter)

res = requests.request(
res = request.request(
cert=self.client_certificate,
data=request_params,
headers=headers,
Expand All @@ -516,17 +531,31 @@ def make_request(self, action, *args, **kwargs):
self.init_ephemeral_auth()
return self.make_request(action, args, kwargs)

self.response = self.get_response(res)
raw_content = self.get_raw_content(res)
self.response = self.get_response(res, raw_content)

def get_raw_content(self, res):
raw_content = None
if res.text is not None and len(res.text) > 0:
raw_content = res.text
elif res.content is not None and len(res.content) > 0:
raw_content = res.content
elif res.raw.data is not None and len(res.raw.data) > 0:
encoding = res.encoding
if encoding == None:
encoding = "utf-8"
raw_content = res.raw.data.decode(encoding)
return raw_content


class XmlApiCall(ApiCall):
def get_response(self, http_response):
def get_response(self, http_response, raw_content):
if http_response.status_code not in SUCCESS_CODES:
raise OscApiException(http_response)
try:
response = xmltodict.parse(http_response.content)
response = xmltodict.parse(raw_content)
except Exception:
response = "Unable to parse response: '{}'".format(http_response.text)
response = "Unable to parse response: '{}'".format(raw_content)
return response


Expand Down Expand Up @@ -576,11 +605,10 @@ class JsonApiCall(ApiCall):
def get_parameters(self, data, action):
return data

def get_response(self, http_response):
def get_response(self, http_response, raw_content):
if http_response.status_code not in SUCCESS_CODES:
raise OscApiException(http_response)

return json.loads(http_response.text)
return json.loads(raw_content)

def build_headers(self, target, json_parameters):
signed_headers = 'host;x-amz-date;x-amz-target'
Expand Down Expand Up @@ -632,7 +660,12 @@ def make_request(self, action, *args, **kwargs):
signed_headers,
)

res = requests.request(
request = requests.Session()
if self.ssl_engine_id:
adapter = HttpsAdapter(self.ssl_engine_id, self.ssl_engine_path, self.ssl_module_path)
request.mount("https://", adapter)

res = request.request(
cert=self.client_certificate,
data=json_params,
headers=headers,
Expand All @@ -652,7 +685,8 @@ def make_request(self, action, *args, **kwargs):
self.init_ephemeral_auth()
return self.make_request(action, args, kwargs)

self.response = self.get_response(res)
raw_content = self.get_raw_content(res)
self.response = self.get_response(res, raw_content)


class IcuCall(JsonApiCall):
Expand Down Expand Up @@ -694,16 +728,15 @@ class DirectLinkCall(JsonApiCall):
API_NAME = 'directlink'
SERVICE = 'OvertureService'

def get_response(self, http_response):
def get_response(self, http_response, raw_content):
if http_response.status_code not in SUCCESS_CODES:
raise OscApiException(http_response)

res = json.loads(http_response.text)
res = json.loads(raw_content)
res['requestid'] = http_response.headers['x-amz-requestid']
return res



class OKMSCall(JsonApiCall):
API_NAME = 'kms'
SERVICE = 'TrentService'
Expand Down Expand Up @@ -785,6 +818,76 @@ def build_headers(self, target, json_parameters):
headers.update(self.build_basic_auth())
return signed_headers, canonical_headers, headers


class HttpsAdapter(requests.adapters.BaseAdapter):
"""
Adapter to use an external SSL engine (thanks to M2Crypto)
https://docs.python-requests.org/en/master/_modules/requests/adapters/
"""

def __init__(self, ssl_engine_id, ssl_engine_path, ssl_module_path):
super(HttpsAdapter, self).__init__()
self.ssl_engine_id = ssl_engine_id
self.ssl_engine_path = ssl_engine_path
self.ssl_module_path = ssl_module_path

def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):

url_parse = urllib.parse.urlparse(request.url)
url_path_query = url_parse.path
if url_parse.query:
url_path_query += "?" + url_parse.query

cert_path = None
key_path = None
try:
(cert_path, key_path) = cert
except ValueError:
raise ValueError("Certificate and/or private key not configured")

ssl_context = self.init_ssl_context(verify=verify, cert_path=cert_path, key_path=key_path)
connection = M2Crypto.httpslib.HTTPSConnection(host=url_parse.netloc, ssl_context=ssl_context)
connection.request(method=request.method, url=url_path_query, body=request.body, headers=request.headers)

resp = urllib3.response.HTTPResponse.from_httplib(connection.getresponse())
response = requests.models.Response()
response.connection = self
response.url = url_parse.geturl()
response.headers = requests.structures.CaseInsensitiveDict(getattr(resp, "headers", {}))
response.reason = getattr(resp, "reason", None)
response.status_code = getattr(resp, "status", None)
response.request = request
response.raw = resp
response.encoding = requests.utils.get_encoding_from_headers(response.headers)
return response

def close(self):
pass

def init_ssl_context(self, verify, cert_path, key_path):
# More details on https://gitlab.com/m2crypto/m2crypto/-/blob/master/src/M2Crypto/SSL/Context.py
ssl_context = M2Crypto.SSL.Context()
if not verify:
ssl_context.set_allow_unknown_ca(True)

ssl_engine = self.init_ssl_engine()
cert = ssl_engine.load_certificate(cert_path)
key = ssl_engine.load_private_key(key_path)
M2Crypto.m2.ssl_ctx_use_x509(ssl_context.ctx, cert.x509) # pylint: disable=E1101 # is part of swig
M2Crypto.m2.ssl_ctx_use_pkey_privkey(ssl_context.ctx, key.pkey) # pylint: disable=E1101 # is part of swig
return ssl_context

def init_ssl_engine(self):
if self.ssl_engine_path is not None:
M2Crypto.Engine.load_dynamic_engine(self.ssl_engine_id, self.ssl_engine_path)
ssl_engine = M2Crypto.Engine.Engine(self.ssl_engine_id)
if self.ssl_module_path is not None:
ssl_engine.ctrl_cmd_string("MODULE_PATH", self.ssl_module_path)

M2Crypto.m2.engine_init(M2Crypto.m2.engine_by_id(self.ssl_engine_id)) # pylint: disable=E1101 # is part of swig
return ssl_engine


def api_connect(service, action, profile=DEFAULT_PROFILE, login=None, password=None, authentication_method=DEFAULT_AUTHENTICATION_METHOD, ephemeral_ak_duration=DEFAULT_EPHEMERAL_AK_DURATION_S, interactive=False, *args, **kwargs):
calls = {
'api': OSCCall,
Expand Down
21 changes: 20 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
fire==0.4.0
requests==2.26.0
xmltodict==0.12.0
pylint==2.9.6
xmltodict==0.12.0
bandit==1.7.0
certifi==2021.5.30
chardet==4.0.0
defusedxml==0.7.1
gitdb==4.0.7
GitPython==3.1.18
idna==2.10
isort==5.8.0
lazy-object-proxy==1.6.0
M2Crypto==0.37.1
mccabe==0.6.1
parameterized==0.8.1
pbr==5.6.0
PyYAML==5.4.1
six==1.16.0
smmap==4.0.0
stevedore==3.3.0
termcolor==1.1.0
toml==0.10.2
urllib3==1.26.5
wrapt==1.12.1
7 changes: 5 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@
entry_points={"console_scripts": ["osc-cli = osc_sdk.sdk:main"]},
install_requires=[
"setuptools",
"fire==0.1.3",
"requests==2.26.0",
"xmltodict==0.11.0",
"fire==0.4.0",
"requests==2.26.0",
"defusedxml==0.7.1",
"M2Crypto==0.37.1",
"urllib3==1.26.5",
],
)

0 comments on commit 87644df

Please sign in to comment.