2013-08-15 16:56:22 -07:00
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
|
|
# not use this file except in compliance with the License. You may obtain
|
|
|
|
# a copy of the License at
|
|
|
|
#
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
#
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
# distributed under the License 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.
|
|
|
|
|
2013-10-15 11:21:56 +08:00
|
|
|
"""Certificate signing functions.
|
|
|
|
|
|
|
|
Call set_subprocess() with the subprocess module. Either Python's
|
|
|
|
subprocess or eventlet.green.subprocess can be used.
|
|
|
|
|
|
|
|
If set_subprocess() is not called, this module will pick Python's subprocess
|
|
|
|
or eventlet.green.subprocess based on if os module is patched by eventlet.
|
|
|
|
"""
|
2012-11-12 19:40:21 +00:00
|
|
|
|
2014-02-04 20:43:07 -05:00
|
|
|
import base64
|
2013-06-20 18:49:26 +02:00
|
|
|
import errno
|
2013-10-15 11:21:56 +08:00
|
|
|
import hashlib
|
2012-11-12 19:40:21 +00:00
|
|
|
import logging
|
2014-02-04 20:43:07 -05:00
|
|
|
import zlib
|
|
|
|
|
2014-01-16 20:33:52 +01:00
|
|
|
import six
|
2012-11-12 19:40:21 +00:00
|
|
|
|
2013-10-15 11:21:56 +08:00
|
|
|
from keystoneclient import exceptions
|
|
|
|
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
subprocess = None
|
|
|
|
LOG = logging.getLogger(__name__)
|
2014-04-12 00:45:27 -04:00
|
|
|
PKI_ASN1_PREFIX = 'MII'
|
2014-02-04 20:43:07 -05:00
|
|
|
PKIZ_PREFIX = 'PKIZ_'
|
|
|
|
PKIZ_CMS_FORM = 'DER'
|
|
|
|
PKI_ASN1_FORM = 'PEM'
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
|
|
|
|
def _ensure_subprocess():
|
|
|
|
# NOTE(vish): late loading subprocess so we can
|
|
|
|
# use the green version if we are in
|
|
|
|
# eventlet.
|
|
|
|
global subprocess
|
|
|
|
if not subprocess:
|
|
|
|
try:
|
|
|
|
from eventlet import patcher
|
2014-02-06 14:11:12 -05:00
|
|
|
if patcher.already_patched:
|
2012-11-12 19:40:21 +00:00
|
|
|
from eventlet.green import subprocess
|
|
|
|
else:
|
|
|
|
import subprocess
|
|
|
|
except ImportError:
|
2013-05-28 09:22:03 -05:00
|
|
|
import subprocess # noqa
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
|
2013-10-15 11:21:56 +08:00
|
|
|
def set_subprocess(_subprocess=None):
|
|
|
|
"""Set subprocess module to use.
|
|
|
|
The subprocess could be eventlet.green.subprocess if using eventlet,
|
|
|
|
or Python's subprocess otherwise.
|
|
|
|
"""
|
|
|
|
global subprocess
|
|
|
|
subprocess = _subprocess
|
|
|
|
|
|
|
|
|
2013-06-20 18:49:26 +02:00
|
|
|
def _check_files_accessible(files):
|
|
|
|
err = None
|
|
|
|
try:
|
|
|
|
for try_file in files:
|
|
|
|
with open(try_file, 'r'):
|
|
|
|
pass
|
|
|
|
except IOError as e:
|
|
|
|
# Catching IOError means there is an issue with
|
|
|
|
# the given file.
|
|
|
|
err = ('Hit OSError in _process_communicate_handle_oserror()\n'
|
|
|
|
'Likely due to %s: %s') % (try_file, e.strerror)
|
|
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
2014-03-10 15:12:15 -04:00
|
|
|
def _process_communicate_handle_oserror(process, data, files):
|
2013-06-20 18:49:26 +02:00
|
|
|
"""Wrapper around process.communicate that checks for OSError."""
|
|
|
|
|
|
|
|
try:
|
2014-03-10 15:12:15 -04:00
|
|
|
output, err = process.communicate(data)
|
2013-06-20 18:49:26 +02:00
|
|
|
except OSError as e:
|
|
|
|
if e.errno != errno.EPIPE:
|
|
|
|
raise
|
|
|
|
# OSError with EPIPE only occurs with Python 2.6.x/old 2.7.x
|
|
|
|
# http://bugs.python.org/issue10963
|
|
|
|
|
|
|
|
# The quick exit is typically caused by the openssl command not being
|
|
|
|
# able to read an input file, so check ourselves if can't read a file.
|
|
|
|
err = _check_files_accessible(files)
|
|
|
|
if process.stderr:
|
2014-03-10 15:12:15 -04:00
|
|
|
msg = process.stderr.read()
|
|
|
|
err = err + msg.decode('utf-8')
|
2014-04-21 16:49:09 -04:00
|
|
|
output = ''
|
2013-06-20 18:49:26 +02:00
|
|
|
retcode = -1
|
|
|
|
else:
|
|
|
|
retcode = process.poll()
|
2014-03-10 15:12:15 -04:00
|
|
|
if err is not None:
|
|
|
|
err = err.decode('utf-8')
|
2013-06-20 18:49:26 +02:00
|
|
|
|
|
|
|
return output, err, retcode
|
|
|
|
|
|
|
|
|
2014-02-04 20:43:07 -05:00
|
|
|
def _encoding_for_form(inform):
|
|
|
|
if inform == PKI_ASN1_FORM:
|
|
|
|
encoding = 'UTF-8'
|
|
|
|
elif inform == PKIZ_CMS_FORM:
|
|
|
|
encoding = 'hex'
|
|
|
|
else:
|
|
|
|
raise ValueError('"inform" must be either %s or %s' %
|
|
|
|
(PKI_ASN1_FORM, PKIZ_CMS_FORM))
|
|
|
|
|
|
|
|
return encoding
|
|
|
|
|
|
|
|
|
|
|
|
def cms_verify(formatted, signing_cert_file_name, ca_file_name,
|
|
|
|
inform=PKI_ASN1_FORM):
|
2013-07-22 13:50:46 -04:00
|
|
|
"""Verifies the signature of the contents IAW CMS syntax.
|
|
|
|
|
|
|
|
:raises: subprocess.CalledProcessError
|
2013-10-15 11:21:56 +08:00
|
|
|
:raises: CertificateConfigError if certificate is not configured properly.
|
2013-07-22 13:50:46 -04:00
|
|
|
"""
|
2012-11-12 19:40:21 +00:00
|
|
|
_ensure_subprocess()
|
2014-02-04 20:43:07 -05:00
|
|
|
if isinstance(formatted, six.string_types):
|
|
|
|
data = bytearray(formatted, _encoding_for_form(inform))
|
|
|
|
else:
|
|
|
|
data = formatted
|
2014-04-21 16:49:09 -04:00
|
|
|
process = subprocess.Popen(['openssl', 'cms', '-verify',
|
|
|
|
'-certfile', signing_cert_file_name,
|
|
|
|
'-CAfile', ca_file_name,
|
|
|
|
'-inform', 'PEM',
|
|
|
|
'-nosmimecap', '-nodetach',
|
|
|
|
'-nocerts', '-noattr'],
|
2012-11-12 19:40:21 +00:00
|
|
|
stdin=subprocess.PIPE,
|
|
|
|
stdout=subprocess.PIPE,
|
2014-03-10 15:12:15 -04:00
|
|
|
stderr=subprocess.PIPE)
|
2013-06-20 18:49:26 +02:00
|
|
|
output, err, retcode = _process_communicate_handle_oserror(
|
2014-03-10 15:12:15 -04:00
|
|
|
process, data, (signing_cert_file_name, ca_file_name))
|
2013-10-15 11:21:56 +08:00
|
|
|
|
|
|
|
# Do not log errors, as some happen in the positive thread
|
|
|
|
# instead, catch them in the calling code and log them there.
|
|
|
|
|
|
|
|
# When invoke the openssl with not exist file, return code 2
|
|
|
|
# and error msg will be returned.
|
|
|
|
# You can get more from
|
|
|
|
# http://www.openssl.org/docs/apps/cms.html#EXIT_CODES
|
|
|
|
#
|
|
|
|
# $ openssl cms -verify -certfile not_exist_file -CAfile \
|
|
|
|
# not_exist_file -inform PEM -nosmimecap -nodetach \
|
|
|
|
# -nocerts -noattr
|
|
|
|
# Error opening certificate file not_exist_file
|
|
|
|
#
|
|
|
|
if retcode == 2:
|
2014-02-04 20:43:07 -05:00
|
|
|
if err.startswith('Error reading S/MIME message'):
|
|
|
|
raise exceptions.CMSError(err)
|
|
|
|
else:
|
|
|
|
raise exceptions.CertificateConfigError(err)
|
2013-10-15 11:21:56 +08:00
|
|
|
elif retcode:
|
2013-01-21 16:51:23 +01:00
|
|
|
# NOTE(dmllr): Python 2.6 compatibility:
|
|
|
|
# CalledProcessError did not have output keyword argument
|
2014-04-21 16:49:09 -04:00
|
|
|
e = subprocess.CalledProcessError(retcode, 'openssl')
|
2013-01-21 16:51:23 +01:00
|
|
|
e.output = err
|
|
|
|
raise e
|
2012-11-12 19:40:21 +00:00
|
|
|
return output
|
|
|
|
|
|
|
|
|
2014-02-04 20:43:07 -05:00
|
|
|
def is_pkiz(token_text):
|
|
|
|
"""Determine if a token a cmsz token
|
|
|
|
|
|
|
|
Checks if the string has the prefix that indicates it is a
|
|
|
|
Crypto Message Syntax, Z compressed token.
|
|
|
|
"""
|
|
|
|
return token_text.startswith(PKIZ_PREFIX)
|
|
|
|
|
|
|
|
|
|
|
|
def pkiz_sign(text,
|
|
|
|
signing_cert_file_name,
|
|
|
|
signing_key_file_name,
|
|
|
|
compression_level=6):
|
|
|
|
signed = cms_sign_data(text,
|
|
|
|
signing_cert_file_name,
|
|
|
|
signing_key_file_name,
|
|
|
|
PKIZ_CMS_FORM)
|
|
|
|
|
|
|
|
compressed = zlib.compress(signed, compression_level)
|
|
|
|
encoded = PKIZ_PREFIX + base64.urlsafe_b64encode(
|
|
|
|
compressed).decode('utf-8')
|
|
|
|
return encoded
|
|
|
|
|
|
|
|
|
|
|
|
def pkiz_uncompress(signed_text):
|
|
|
|
text = signed_text[len(PKIZ_PREFIX):].encode('utf-8')
|
|
|
|
unencoded = base64.urlsafe_b64decode(text)
|
|
|
|
uncompressed = zlib.decompress(unencoded)
|
|
|
|
return uncompressed
|
|
|
|
|
|
|
|
|
|
|
|
def pkiz_verify(signed_text, signing_cert_file_name, ca_file_name):
|
|
|
|
uncompressed = pkiz_uncompress(signed_text)
|
|
|
|
return cms_verify(uncompressed, signing_cert_file_name, ca_file_name,
|
|
|
|
inform=PKIZ_CMS_FORM)
|
|
|
|
|
|
|
|
|
|
|
|
# This function is deprecated and will be removed once the ASN1 token format
|
|
|
|
# is no longer required. It is only here to be used for testing.
|
2012-11-12 19:40:21 +00:00
|
|
|
def token_to_cms(signed_text):
|
|
|
|
copy_of_text = signed_text.replace('-', '/')
|
|
|
|
|
2014-04-21 16:49:09 -04:00
|
|
|
formatted = '-----BEGIN CMS-----\n'
|
2012-11-12 19:40:21 +00:00
|
|
|
line_length = 64
|
|
|
|
while len(copy_of_text) > 0:
|
|
|
|
if (len(copy_of_text) > line_length):
|
|
|
|
formatted += copy_of_text[:line_length]
|
|
|
|
copy_of_text = copy_of_text[line_length:]
|
|
|
|
else:
|
|
|
|
formatted += copy_of_text
|
2014-04-21 16:49:09 -04:00
|
|
|
copy_of_text = ''
|
|
|
|
formatted += '\n'
|
2012-11-12 19:40:21 +00:00
|
|
|
|
2014-04-21 16:49:09 -04:00
|
|
|
formatted += '-----END CMS-----\n'
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
return formatted
|
|
|
|
|
|
|
|
|
|
|
|
def verify_token(token, signing_cert_file_name, ca_file_name):
|
|
|
|
return cms_verify(token_to_cms(token),
|
|
|
|
signing_cert_file_name,
|
|
|
|
ca_file_name)
|
|
|
|
|
|
|
|
|
2014-04-12 00:45:27 -04:00
|
|
|
def is_asn1_token(token):
|
2013-07-09 18:28:34 +02:00
|
|
|
"""Determine if a token appears to be PKI-based.
|
|
|
|
|
2012-11-12 19:40:21 +00:00
|
|
|
thx to ayoung for sorting this out.
|
|
|
|
|
2014-02-16 12:03:58 -06:00
|
|
|
base64 decoded hex representation of MII is 3082::
|
|
|
|
|
|
|
|
In [3]: binascii.hexlify(base64.b64decode('MII='))
|
|
|
|
Out[3]: '3082'
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
re: http://www.itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf
|
|
|
|
|
2014-02-16 12:03:58 -06:00
|
|
|
::
|
|
|
|
|
|
|
|
pg4: For tags from 0 to 30 the first octet is the identfier
|
|
|
|
pg10: Hex 30 means sequence, followed by the length of that sequence.
|
|
|
|
pg5: Second octet is the length octet
|
|
|
|
first bit indicates short or long form, next 7 bits encode the
|
|
|
|
number of subsequent octets that make up the content length octets
|
|
|
|
as an unsigned binary int
|
2012-11-12 19:40:21 +00:00
|
|
|
|
2014-02-16 12:03:58 -06:00
|
|
|
82 = 10000010 (first bit indicates long form)
|
|
|
|
0000010 = 2 octets of content length
|
|
|
|
so read the next 2 octets to get the length of the content.
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
In the case of a very large content length there could be a requirement to
|
|
|
|
have more than 2 octets to designate the content length, therefore
|
|
|
|
requiring us to check for MIM, MIQ, etc.
|
2014-02-16 12:03:58 -06:00
|
|
|
|
|
|
|
::
|
|
|
|
|
|
|
|
In [4]: base64.b64encode(binascii.a2b_hex('3083'))
|
|
|
|
Out[4]: 'MIM='
|
|
|
|
In [5]: base64.b64encode(binascii.a2b_hex('3084'))
|
|
|
|
Out[5]: 'MIQ='
|
|
|
|
Checking for MI would become invalid at 16 octets of content length
|
|
|
|
10010000 = 90
|
|
|
|
In [6]: base64.b64encode(binascii.a2b_hex('3090'))
|
|
|
|
Out[6]: 'MJA='
|
|
|
|
Checking for just M is insufficient
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
But we will only check for MII:
|
2014-02-16 12:03:58 -06:00
|
|
|
Max length of the content using 2 octets is 7FFF or 32767.
|
|
|
|
|
2012-11-12 19:40:21 +00:00
|
|
|
It's not practical to support a token of this length or greater in http
|
|
|
|
therefore, we will check for MII only and ignore the case of larger tokens
|
2013-07-09 18:28:34 +02:00
|
|
|
"""
|
2014-04-12 00:45:27 -04:00
|
|
|
return token[:3] == PKI_ASN1_PREFIX
|
|
|
|
|
|
|
|
|
|
|
|
def is_ans1_token(token):
|
|
|
|
"""Deprecated. Use is_asn1_token() instead."""
|
2014-04-21 16:49:09 -04:00
|
|
|
LOG.warning('The function is_ans1_token() is deprecated, '
|
|
|
|
'use is_asn1_token() instead.')
|
2014-04-12 00:45:27 -04:00
|
|
|
return is_asn1_token(token)
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
|
2014-02-04 20:43:07 -05:00
|
|
|
def cms_sign_text(data_to_sign, signing_cert_file_name, signing_key_file_name):
|
|
|
|
return cms_sign_data(data_to_sign, signing_cert_file_name,
|
|
|
|
signing_key_file_name)
|
|
|
|
|
|
|
|
|
|
|
|
def cms_sign_data(data_to_sign, signing_cert_file_name, signing_key_file_name,
|
|
|
|
outform=PKI_ASN1_FORM):
|
2013-06-21 19:04:50 +02:00
|
|
|
"""Uses OpenSSL to sign a document.
|
|
|
|
|
2012-11-12 19:40:21 +00:00
|
|
|
Produces a Base64 encoding of a DER formatted CMS Document
|
|
|
|
http://en.wikipedia.org/wiki/Cryptographic_Message_Syntax
|
2014-02-04 20:43:07 -05:00
|
|
|
|
|
|
|
:param data_to_sign: data to sign
|
|
|
|
:param signing_cert_file_name: path to the X509 certificate containing
|
|
|
|
the public key associated with the private key used to sign the data
|
|
|
|
:param signing_key_file_name: path to the private key used to sign
|
|
|
|
the data
|
|
|
|
:param outform: Format for the signed document PKIZ_CMS_FORM or
|
|
|
|
PKI_ASN1_FORM
|
|
|
|
|
|
|
|
|
2012-11-12 19:40:21 +00:00
|
|
|
"""
|
|
|
|
_ensure_subprocess()
|
2014-02-04 20:43:07 -05:00
|
|
|
if isinstance(data_to_sign, six.string_types):
|
|
|
|
data = bytearray(data_to_sign, encoding='utf-8')
|
|
|
|
else:
|
|
|
|
data = data_to_sign
|
2014-04-21 16:49:09 -04:00
|
|
|
process = subprocess.Popen(['openssl', 'cms', '-sign',
|
|
|
|
'-signer', signing_cert_file_name,
|
|
|
|
'-inkey', signing_key_file_name,
|
|
|
|
'-outform', 'PEM',
|
|
|
|
'-nosmimecap', '-nodetach',
|
|
|
|
'-nocerts', '-noattr'],
|
2012-11-12 19:40:21 +00:00
|
|
|
stdin=subprocess.PIPE,
|
|
|
|
stdout=subprocess.PIPE,
|
2014-03-10 15:12:15 -04:00
|
|
|
stderr=subprocess.PIPE)
|
2013-06-20 18:49:26 +02:00
|
|
|
|
|
|
|
output, err, retcode = _process_communicate_handle_oserror(
|
2014-03-10 15:12:15 -04:00
|
|
|
process, data, (signing_cert_file_name, signing_key_file_name))
|
2013-06-20 18:49:26 +02:00
|
|
|
|
2014-03-10 15:12:15 -04:00
|
|
|
if retcode or ('Error' in err):
|
2014-05-19 17:13:01 +02:00
|
|
|
LOG.error('Signing error: %s', err)
|
2014-02-04 20:43:07 -05:00
|
|
|
if retcode == 3:
|
|
|
|
LOG.error('Signing error: Unable to load certificate - '
|
|
|
|
'ensure you have configured PKI with '
|
|
|
|
'"keystone-manage pki_setup"')
|
|
|
|
else:
|
|
|
|
LOG.error('Signing error: %s', err)
|
2014-04-21 16:49:09 -04:00
|
|
|
raise subprocess.CalledProcessError(retcode, 'openssl')
|
2014-02-04 20:43:07 -05:00
|
|
|
if outform == PKI_ASN1_FORM:
|
|
|
|
return output.decode('utf-8')
|
|
|
|
else:
|
|
|
|
return output
|
2012-11-12 19:40:21 +00:00
|
|
|
|
|
|
|
|
|
|
|
def cms_sign_token(text, signing_cert_file_name, signing_key_file_name):
|
2014-02-04 20:43:07 -05:00
|
|
|
output = cms_sign_data(text, signing_cert_file_name, signing_key_file_name)
|
2012-11-12 19:40:21 +00:00
|
|
|
return cms_to_token(output)
|
|
|
|
|
|
|
|
|
|
|
|
def cms_to_token(cms_text):
|
|
|
|
|
2014-04-21 16:49:09 -04:00
|
|
|
start_delim = '-----BEGIN CMS-----'
|
|
|
|
end_delim = '-----END CMS-----'
|
2012-11-12 19:40:21 +00:00
|
|
|
signed_text = cms_text
|
|
|
|
signed_text = signed_text.replace('/', '-')
|
|
|
|
signed_text = signed_text.replace(start_delim, '')
|
|
|
|
signed_text = signed_text.replace(end_delim, '')
|
|
|
|
signed_text = signed_text.replace('\n', '')
|
|
|
|
|
|
|
|
return signed_text
|
|
|
|
|
|
|
|
|
2014-04-08 19:50:09 -05:00
|
|
|
def cms_hash_token(token_id, mode='md5'):
|
2013-07-09 18:28:34 +02:00
|
|
|
"""Hash PKI tokens.
|
|
|
|
|
2014-02-04 20:43:07 -05:00
|
|
|
return: for asn1 or pkiz tokens, returns the hash of the passed in token
|
2012-11-12 19:40:21 +00:00
|
|
|
otherwise, returns what it was passed in.
|
|
|
|
"""
|
|
|
|
if token_id is None:
|
|
|
|
return None
|
2014-02-04 20:43:07 -05:00
|
|
|
if is_asn1_token(token_id) or is_pkiz(token_id):
|
2014-04-08 19:50:09 -05:00
|
|
|
hasher = hashlib.new(mode)
|
2014-01-16 20:33:52 +01:00
|
|
|
if isinstance(token_id, six.text_type):
|
|
|
|
token_id = token_id.encode('utf-8')
|
2012-11-12 19:40:21 +00:00
|
|
|
hasher.update(token_id)
|
|
|
|
return hasher.hexdigest()
|
|
|
|
else:
|
|
|
|
return token_id
|