Remove NSSConnection from the Python RPC module

NSSConnection was causing a lot of trouble in the past and there is
a lot of logic around it just to make it not fail. What's more,
when using NSS to create an SSL connection in FIPS mode, NSS
always requires database password which makes the `ipa` command
totally unusable.

NSSConnection is therefore replaced with Python's
httplib.HTTPSConnection which is OpenSSL based.

The HTTPSConnection is set up to handle authentication with client
certificate for connections to Dogtag server as RA agent. It allows
to handle client cert/private key in separate files and also
encrypted private key files.

https://fedorahosted.org/freeipa/ticket/5695

Reviewed-By: Jan Cholasta <jcholast@redhat.com>
This commit is contained in:
Stanislav Laznicka 2016-12-20 10:05:36 +01:00 committed by Jan Cholasta
parent 98e3b14a04
commit dfd560a190
4 changed files with 169 additions and 57 deletions

View File

@ -504,6 +504,17 @@ class Env(object):
if 'nss_dir' not in self:
self.nss_dir = self._join('confdir', 'nssdb')
if 'tls_ca_cert' not in self:
self.tls_ca_cert = self._join('confdir', 'ca.crt')
# having tls_ca_cert an absolute path could help us extending this
# in the future for different certificate providers simply by adding
# a prefix to the path
if not path.isabs(self.tls_ca_cert):
raise errors.EnvironmentError(
"tls_ca_cert has to be an absolute path to a CA certificate, "
"got '{}'".format(self.tls_ca_cert))
# Set plugins_on_demand:
if 'plugins_on_demand' not in self:
self.plugins_on_demand = (self.context == 'cli')

View File

@ -226,6 +226,7 @@ DEFAULT_CONFIG = (
('conf_default', object), # File containing context independent config
('plugins_on_demand', object), # Whether to finalize plugins on-demand (bool)
('nss_dir', object), # Path to nssdb, default {confdir}/nssdb
('tls_ca_cert', object), # Path to CA cert file
# Set in Env._finalize_core():
('in_server', object), # Whether or not running in-server (bool)

View File

@ -44,7 +44,7 @@ import gzip
import gssapi
from dns import resolver, rdatatype
from dns.exception import DNSException
from nss.error import NSPRError
from ssl import SSLError
import six
from six.moves import urllib
@ -60,8 +60,7 @@ from ipapython import kernel_keyring
from ipapython.cookie import Cookie
from ipapython.dnsutil import DNSName
from ipalib.text import _
import ipapython.nsslib
from ipapython.nsslib import NSSConnection
from ipalib.util import create_https_connection
from ipalib.krb_utils import KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN, KRB5KRB_AP_ERR_TKT_EXPIRED, \
KRB5_FCC_PERM, KRB5_FCC_NOFILE, KRB5_CC_FORMAT, \
KRB5_REALM_CANT_RESOLVE, KRB5_CC_NOTFOUND, get_principal
@ -542,48 +541,20 @@ class LanguageAwareTransport(MultiProtocolTransport):
return (host, extra_headers, x509)
class SSLTransport(LanguageAwareTransport):
"""Handles an HTTPS transaction to an XML-RPC server."""
def get_connection_dbdir(self):
"""
If there is a connections open it may have already initialized
NSS database. Return the database location used by the connection.
"""
for value in context.__dict__.values():
if not isinstance(value, Connection):
continue
if not isinstance(
getattr(value.conn, '_ServerProxy__transport', None),
SSLTransport):
continue
if hasattr(value.conn._ServerProxy__transport, 'dbdir'):
return value.conn._ServerProxy__transport.dbdir
return None
def make_connection(self, host):
host, self._extra_headers, _x509 = self.get_host_info(host)
if self._connection and host == self._connection[0]:
return self._connection[1]
dbdir = context.nss_dir
connection_dbdir = self.get_connection_dbdir()
if connection_dbdir:
# If an existing connection is already using the same NSS
# database there is no need to re-initialize.
no_init = dbdir == connection_dbdir
else:
# If the NSS database is already being used there is no
# need to re-initialize.
no_init = dbdir == ipapython.nsslib.current_dbdir
conn = NSSConnection(host, 443, dbdir=dbdir, no_init=no_init,
tls_version_min=api.env.tls_version_min,
tls_version_max=api.env.tls_version_max)
self.dbdir=dbdir
conn = create_https_connection(
host, 443,
api.env.tls_ca_cert,
tls_version_min=api.env.tls_version_min,
tls_version_max=api.env.tls_version_max)
conn.connect()
@ -963,15 +934,15 @@ class RPCClient(Connectible):
return session_url
def create_connection(self, ccache=None, verbose=None, fallback=None,
delegate=None, nss_dir=None):
delegate=None, ca_certfile=None):
if verbose is None:
verbose = self.api.env.verbose
if fallback is None:
fallback = self.api.env.fallback
if delegate is None:
delegate = self.api.env.delegate
if nss_dir is None:
nss_dir = self.api.env.nss_dir
if ca_certfile is None:
ca_certfile = self.api.env.tls_ca_cert
try:
rpc_uri = self.env[self.env_rpc_uri_key]
principal = get_principal(ccache_name=ccache)
@ -989,7 +960,7 @@ class RPCClient(Connectible):
except (errors.CCacheError, ValueError):
# No session key, do full Kerberos auth
pass
context.nss_dir = nss_dir
context.ca_certfile = ca_certfile
urls = self.get_url_list(rpc_uri)
serverproxy = None
for url in urls:
@ -1099,7 +1070,7 @@ class RPCClient(Connectible):
error=e.faultString,
server=server,
)
except NSPRError as e:
except SSLError as e:
raise NetworkError(uri=server, error=str(e))
except ProtocolError as e:
# By catching a 401 here we can detect the case where we have
@ -1116,22 +1087,9 @@ class RPCClient(Connectible):
# This shouldn't happen if we have a session but it isn't fatal.
pass
# Create a new serverproxy with the non-session URI. If there
# is an existing connection we need to save the NSS dbdir so
# we can skip an unnecessary NSS_Initialize() and avoid
# NSS_Shutdown issues.
# Create a new serverproxy with the non-session URI
serverproxy = self.create_connection(os.environ.get('KRB5CCNAME'), self.env.verbose, self.env.fallback, self.env.delegate)
dbdir = None
current_conn = getattr(context, self.id, None)
if current_conn is not None:
dbdir = getattr(current_conn.conn._ServerProxy__transport, 'dbdir', None)
if dbdir is not None:
self.debug('Using dbdir %s' % dbdir)
setattr(context, self.id, Connection(serverproxy, self.disconnect))
if dbdir is not None:
current_conn = getattr(context, self.id, None)
current_conn.conn._ServerProxy__transport.dbdir = dbdir
return self.forward(name, *args, **kw)
raise NetworkError(uri=server, error=e.errmsg)
except socket.error as e:

View File

@ -33,6 +33,7 @@ import decimal
import dns
import encodings
import sys
import ssl
from weakref import WeakKeyDictionary
import netaddr
@ -42,8 +43,17 @@ from dns.resolver import NXDOMAIN
from netaddr.core import AddrFormatError
import six
try:
from httplib import HTTPSConnection
except ImportError:
# Python 3
from http.client import HTTPSConnection
from ipalib import errors, messages
from ipalib.constants import DOMAIN_LEVEL_0
from ipalib.constants import (
DOMAIN_LEVEL_0,
TLS_VERSIONS, TLS_VERSION_MINIMAL
)
from ipalib.text import _
from ipapython.ssh import SSHPublicKey
from ipapython.dn import DN, RDN
@ -51,6 +61,7 @@ from ipapython.dnsutil import DNSName
from ipapython.dnsutil import resolve_ip_addresses
from ipapython.ipa_log_manager import root_logger
if six.PY3:
unicode = str
@ -187,6 +198,137 @@ def normalize_zone(zone):
return zone
def get_proper_tls_version_span(tls_version_min, tls_version_max):
"""
This function checks whether the given TLS versions are known in
FreeIPA and that these versions fulfill the requirements for minimal
TLS version (see
`ipalib.constants: TLS_VERSIONS, TLS_VERSION_MINIMAL`).
:param tls_version_min:
the lower value in the TLS min-max span, raised to the lowest
allowed value if too low
:param tls_version_max:
the higher value in the TLS min-max span, raised to tls_version_min
if lower than TLS_VERSION_MINIMAL
:raises: ValueError
"""
min_allowed_idx = TLS_VERSIONS.index(TLS_VERSION_MINIMAL)
try:
min_version_idx = TLS_VERSIONS.index(tls_version_min)
except ValueError:
raise ValueError("tls_version_min ('{val}') is not a known "
"TLS version.".format(val=tls_version_min))
try:
max_version_idx = TLS_VERSIONS.index(tls_version_max)
except ValueError:
raise ValueError("tls_version_max ('{val}') is not a known "
"TLS version.".format(val=tls_version_max))
if min_version_idx > max_version_idx:
raise ValueError("tls_version_min is higher than "
"tls_version_max.")
if min_version_idx < min_allowed_idx:
min_version_idx = min_allowed_idx
root_logger.warning("tls_version_min set too low ('{old}'),"
"using '{new}' instead"
.format(old=tls_version_min,
new=TLS_VERSIONS[min_version_idx]))
if max_version_idx < min_allowed_idx:
max_version_idx = min_version_idx
root_logger.warning("tls_version_max set too low ('{old}'),"
"using '{new}' instead"
.format(old=tls_version_max,
new=TLS_VERSIONS[max_version_idx]))
return TLS_VERSIONS[min_version_idx:max_version_idx+1]
def create_https_connection(
host, port=HTTPSConnection.default_port,
cafile=None,
client_certfile=None, client_keyfile=None,
keyfile_passwd=None,
tls_version_min="tls1.1",
tls_version_max="tls1.2",
**kwargs
):
"""
Create a customized HTTPSConnection object.
:param host: The host to connect to
:param port: The port to connect to, defaults to
HTTPSConnection.default_port
:param cafile: A PEM-format file containning the trusted
CA certificates
:param client_certfile:
A PEM-format client certificate file that will be used to
identificate the user to the server.
:param client_keyfile:
A file with the client private key. If this argument is not
supplied, the key will be sought in client_certfile.
:param keyfile_passwd:
A path to the file which stores the password that is used to
encrypt client_keyfile. Leave default value if the keyfile
is not encrypted.
:returns An established HTTPS connection to host:port
"""
# pylint: disable=no-member
tls_cutoff_map = {
"ssl2": ssl.OP_NO_SSLv2,
"ssl3": ssl.OP_NO_SSLv3,
"tls1.0": ssl.OP_NO_TLSv1,
"tls1.1": ssl.OP_NO_TLSv1_1,
"tls1.2": ssl.OP_NO_TLSv1_2,
}
# pylint: enable=no-member
if cafile is None:
raise RuntimeError("cafile argument is required to perform server "
"certificate verification")
# remove the slice of negating protocol options according to options
tls_span = get_proper_tls_version_span(tls_version_min, tls_version_max)
# official Python documentation states that the best option to get
# TLSv1 and later is to setup SSLContext with PROTOCOL_SSLv23
# and then negate the insecure SSLv2 and SSLv3
# pylint: disable=no-member
ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
ctx.options |= (
ssl.OP_ALL | ssl.OP_NO_COMPRESSION | ssl.OP_SINGLE_DH_USE |
ssl.OP_SINGLE_ECDH_USE
)
# pylint: enable=no-member
# set up the correct TLS version flags for the SSL context
for version in TLS_VERSIONS:
if version in tls_span:
# make sure the required TLS versions are available if Python
# decides to modify the default TLS flags
ctx.options &= ~tls_cutoff_map[version]
else:
# disable all TLS versions not in tls_span
ctx.options |= tls_cutoff_map[version]
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.check_hostname = True
ctx.load_verify_locations(cafile)
if client_certfile is not None:
if keyfile_passwd is not None:
with open(keyfile_passwd) as pwd_f:
passwd = pwd_f.read()
else:
passwd = None
ctx.load_cert_chain(client_certfile, client_keyfile, passwd)
return HTTPSConnection(host, port, context=ctx, **kwargs)
def validate_dns_label(dns_label, allow_underscore=False, allow_slash=False):
base_chars = 'a-z0-9'
extra_chars = ''