From c5a1a9e711b9d22f70403c338629e370ffa98d5a Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Wed, 4 Jan 2017 15:53:31 +0000 Subject: [PATCH] console: introduce the VeNCrypt RFB authentication scheme Provide an implementation for the VeNCrypt RFB authentication scheme, which uses TLS and x509 certificates to provide both encryption and mutual authentication / authorization. Based on earlier work by Solly Ross Change-Id: I6a63d2535e86faf369ed1c0eeba6cb5a52252b80 Co-authored-by: Stephen Finucane Implements: bp websocket-proxy-to-host-security --- nova/conf/vnc.py | 37 +++- nova/console/rfb/auth.py | 14 ++ nova/console/rfb/auths.py | 2 + nova/console/rfb/authvencrypt.py | 128 +++++++++++ nova/tests/unit/console/rfb/test_auth.py | 5 +- .../unit/console/rfb/test_authvencrypt.py | 203 ++++++++++++++++++ ...oxy-to-host-security-c3eca0647b0cbc02.yaml | 3 + 7 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 nova/console/rfb/authvencrypt.py create mode 100644 nova/tests/unit/console/rfb/test_authvencrypt.py diff --git a/nova/conf/vnc.py b/nova/conf/vnc.py index cda136b1b817..710bcdd3f364 100644 --- a/nova/conf/vnc.py +++ b/nova/conf/vnc.py @@ -217,7 +217,7 @@ Related options: cfg.ListOpt( 'auth_schemes', item_type=types.String( - choices=['none'] + choices=['none', 'vencrypt'] ), default=['none'], help=""" @@ -231,6 +231,41 @@ first. Possible values: * "none": allow connection without authentication +"""), + cfg.StrOpt( + 'vencrypt_client_key', + help="""The path to the client certificate PEM file (for x509) + +The fully qualified path to a PEM file containing the private key which the VNC +proxy server presents to the compute node during VNC authentication. + +Related options: + +* ``vnc.auth_schemes``: must include "vencrypt" +* ``vnc.vencrypt_client_cert```: must also be set +"""), + cfg.StrOpt( + 'vencrypt_client_cert', + help="""The path to the client key file (for x509) + +The fully qualified path to a PEM file containing the x509 certificate which +the VNC proxy server presents to the compute node during VNC authentication. + +Realted options: + +* ``vnc.auth_schemes``: must include "vencrypt" +* ``vnc.vencrypt_client_key```: must also be set +"""), + cfg.StrOpt( + 'vencrypt_ca_certs', + help="""The path to the CA certificate PEM file + +The fully qualified path to a PEM file containing one or more x509 certificates +for the certificate authorities used by the compute node VNC server. + +Related options: + +* ``vnc.auth_schemes``: must include "vencrypt" """), ] diff --git a/nova/console/rfb/auth.py b/nova/console/rfb/auth.py index 39e06b93f0e8..9a4a4ba28f84 100644 --- a/nova/console/rfb/auth.py +++ b/nova/console/rfb/auth.py @@ -18,6 +18,7 @@ import enum import six VERSION_LENGTH = 12 +SUBTYPE_LENGTH = 4 AUTH_STATUS_FAIL = b"\x00" AUTH_STATUS_PASS = b"\x01" @@ -39,6 +40,19 @@ class AuthType(enum.IntEnum): MSLOGON = 0xfffffffa # Used by UltraVNC +class AuthVeNCryptSubtype(enum.IntEnum): + + PLAIN = 256 + TLSNONE = 257 + TLSVNC = 258 + TLSPLAIN = 259 + X509NONE = 260 + X509VNC = 261 + X509PLAIN = 262 + X509SASL = 263 + TLSSASL = 264 + + @six.add_metaclass(abc.ABCMeta) class RFBAuthScheme(object): diff --git a/nova/console/rfb/auths.py b/nova/console/rfb/auths.py index 62f73e703c2c..74828e161452 100644 --- a/nova/console/rfb/auths.py +++ b/nova/console/rfb/auths.py @@ -15,6 +15,7 @@ from oslo_config import cfg from nova.console.rfb import authnone +from nova.console.rfb import authvencrypt from nova import exception CONF = cfg.CONF @@ -24,6 +25,7 @@ class RFBAuthSchemeList(object): AUTH_SCHEME_MAP = { "none": authnone.RFBAuthSchemeNone, + "vencrypt": authvencrypt.RFBAuthSchemeVeNCrypt, } def __init__(self): diff --git a/nova/console/rfb/authvencrypt.py b/nova/console/rfb/authvencrypt.py new file mode 100644 index 000000000000..7e6fd873e296 --- /dev/null +++ b/nova/console/rfb/authvencrypt.py @@ -0,0 +1,128 @@ +# Copyright (c) 2014-2016 Red Hat, Inc +# +# 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. + +import ssl +import struct + +from oslo_config import cfg +from oslo_log import log as logging + +from nova.console.rfb import auth +from nova import exception +from nova.i18n import _, _LI + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class RFBAuthSchemeVeNCrypt(auth.RFBAuthScheme): + """A security proxy helper which uses VeNCrypt. + + This security proxy helper uses the VeNCrypt security + type to achieve SSL/TLS-secured VNC. It supports both + standard SSL/TLS encryption and SSL/TLS encryption with + x509 authentication. + + Refer to https://www.berrange.com/~dan/vencrypt.txt for + a brief overview of the protocol. + """ + + def security_type(self): + return auth.AuthType.VENCRYPT + + def security_handshake(self, compute_sock): + def recv(num): + b = compute_sock.recv(num) + if len(b) != num: + reason = _("Short read from compute socket, wanted " + "%(wanted)d bytes but got %(got)d") % { + 'wanted': num, 'got': len(b)} + raise exception.RFBAuthHandshakeFailed(reason=reason) + return b + + # get the VeNCrypt version from the server + maj_ver = ord(recv(1)) + min_ver = ord(recv(1)) + + LOG.debug("Server sent VeNCrypt version " + "%(maj)s.%(min)s", {'maj': maj_ver, 'min': min_ver}) + + if maj_ver != 0 or min_ver != 2: + reason = _("Only VeNCrypt version 0.2 is supported by this " + "proxy, but the server wanted to use version " + "%(maj)s.%(min)s") % {'maj': maj_ver, 'min': min_ver} + raise exception.RFBAuthHandshakeFailed(reason=reason) + + # use version 0.2 + compute_sock.sendall(b"\x00\x02") + + can_use_version = ord(recv(1)) + + if can_use_version > 0: + reason = _("Server could not use VeNCrypt version 0.2") + raise exception.RFBAuthHandshakeFailed(reason=reason) + + # get the supported sub-auth types + sub_types_cnt = ord(recv(1)) + sub_types_raw = recv(sub_types_cnt * auth.SUBTYPE_LENGTH) + sub_types = struct.unpack('!' + str(sub_types_cnt) + 'I', + sub_types_raw) + + LOG.debug("Server supports VeNCrypt sub-types %s", sub_types) + + if auth.AuthVeNCryptSubtype.X509NONE not in sub_types: + reason = _("Server does not support the x509None (%s) VeNCrypt" + " sub-auth type") % \ + auth.AuthVeNCryptSubtype.X509NONE + raise exception.RFBAuthHandshakeFailed(reason=reason) + + LOG.debug("Attempting to use the x509None (%s) auth sub-type", + auth.AuthVeNCryptSubtype.X509NONE) + + compute_sock.sendall(struct.pack( + '!I', auth.AuthVeNCryptSubtype.X509NONE)) + + # NB(sross): the spec is missing a U8 here that's used in + # multiple implementations (e.g. QEMU, GTK-VNC). 1 means + # acceptance, 0 means failure (unlike the rest of RFB) + auth_accepted = ord(recv(1)) + if auth_accepted == 0: + reason = _("Server didn't accept the requested auth sub-type") + raise exception.RFBAuthHandshakeFailed(reason=reason) + + LOG.debug("Server accepted the requested sub-auth type") + + if (CONF.vnc.vencrypt_client_key and + CONF.vnc.vencrypt_client_cert): + client_key = CONF.vnc.vencrypt_client_key + client_cert = CONF.vnc.vencrypt_client_cert + else: + client_key = None + client_cert = None + + try: + wrapped_sock = ssl.wrap_socket( + compute_sock, + keyfile=client_key, + certfile=client_cert, + server_side=False, + cert_reqs=ssl.CERT_REQUIRED, + ca_certs=CONF.vnc.vencrypt_ca_certs) + + LOG.info(_LI("VeNCrypt security handshake accepted")) + return wrapped_sock + + except ssl.SSLError as e: + reason = _("Error establishing TLS connection to server: %s") % e + raise exception.RFBAuthHandshakeFailed(reason=reason) diff --git a/nova/tests/unit/console/rfb/test_auth.py b/nova/tests/unit/console/rfb/test_auth.py index 72912c8880df..c3bc02daf759 100644 --- a/nova/tests/unit/console/rfb/test_auth.py +++ b/nova/tests/unit/console/rfb/test_auth.py @@ -26,13 +26,14 @@ class RFBAuthSchemeListTestCase(test.NoDBTestCase): def setUp(self): super(RFBAuthSchemeListTestCase, self).setUp() - self.flags(auth_schemes=["none"], group="vnc") + self.flags(auth_schemes=["none", "vencrypt"], group="vnc") def test_load_ok(self): schemelist = auths.RFBAuthSchemeList() security_types = sorted(schemelist.schemes.keys()) - self.assertEqual(security_types, [auth.AuthType.NONE]) + self.assertEqual(security_types, [auth.AuthType.NONE, + auth.AuthType.VENCRYPT]) def test_load_unknown(self): """Ensure invalid auth schemes are not supported. diff --git a/nova/tests/unit/console/rfb/test_authvencrypt.py b/nova/tests/unit/console/rfb/test_authvencrypt.py new file mode 100644 index 000000000000..1c1c24cc49ce --- /dev/null +++ b/nova/tests/unit/console/rfb/test_authvencrypt.py @@ -0,0 +1,203 @@ +# Copyright (c) 2016 Red Hat, Inc +# +# 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. + +import ssl +import struct + +import mock + +from nova.console.rfb import auth +from nova.console.rfb import authvencrypt +from nova import exception +from nova import test + + +class RFBAuthSchemeVeNCryptTestCase(test.NoDBTestCase): + + def setUp(self): + super(RFBAuthSchemeVeNCryptTestCase, self).setUp() + + self.scheme = authvencrypt.RFBAuthSchemeVeNCrypt() + self.compute_sock = mock.MagicMock() + + self.compute_sock.recv.side_effect = [] + + self.expected_calls = [] + + self.flags(vencrypt_ca_certs="/certs/ca.pem", group="vnc") + + def _expect_send(self, val): + self.expected_calls.append(mock.call.sendall(val)) + + def _expect_recv(self, amt, ret_val): + self.expected_calls.append(mock.call.recv(amt)) + self.compute_sock.recv.side_effect = ( + list(self.compute_sock.recv.side_effect) + [ret_val]) + + @mock.patch.object(ssl, "wrap_socket", return_value="wrapped") + def test_security_handshake_with_x509(self, mock_socket): + self.flags(vencrypt_client_key='/certs/keyfile', + vencrypt_client_cert='/certs/cert.pem', + group="vnc") + + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x02") + + self._expect_send(b"\x00\x02") + self._expect_recv(1, "\x00") + + self._expect_recv(1, "\x02") + subtypes_raw = [auth.AuthVeNCryptSubtype.X509NONE, + auth.AuthVeNCryptSubtype.X509VNC] + subtypes = struct.pack('!2I', *subtypes_raw) + self._expect_recv(8, subtypes) + + self._expect_send(struct.pack('!I', subtypes_raw[0])) + + self._expect_recv(1, "\x01") + + self.assertEqual("wrapped", self.scheme.security_handshake( + self.compute_sock)) + + mock_socket.assert_called_once_with( + self.compute_sock, + keyfile='/certs/keyfile', + certfile='/certs/cert.pem', + server_side=False, + cert_reqs=ssl.CERT_REQUIRED, + ca_certs='/certs/ca.pem') + + self.assertEqual(self.expected_calls, self.compute_sock.mock_calls) + + @mock.patch.object(ssl, "wrap_socket", return_value="wrapped") + def test_security_handshake_without_x509(self, mock_socket): + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x02") + + self._expect_send(b"\x00\x02") + self._expect_recv(1, "\x00") + + self._expect_recv(1, "\x02") + subtypes_raw = [auth.AuthVeNCryptSubtype.X509NONE, + auth.AuthVeNCryptSubtype.X509VNC] + subtypes = struct.pack('!2I', *subtypes_raw) + self._expect_recv(8, subtypes) + + self._expect_send(struct.pack('!I', subtypes_raw[0])) + + self._expect_recv(1, "\x01") + + self.assertEqual("wrapped", self.scheme.security_handshake( + self.compute_sock)) + mock_socket.assert_called_once_with( + self.compute_sock, + keyfile=None, + certfile=None, + server_side=False, + cert_reqs=ssl.CERT_REQUIRED, + ca_certs='/certs/ca.pem' + ) + + self.assertEqual(self.expected_calls, self.compute_sock.mock_calls) + + def _test_security_handshake_fails(self): + self.assertRaises(exception.RFBAuthHandshakeFailed, + self.scheme.security_handshake, + self.compute_sock) + self.assertEqual(self.expected_calls, self.compute_sock.mock_calls) + + def test_security_handshake_fails_on_low_version(self): + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x01") + + self._test_security_handshake_fails() + + def test_security_handshake_fails_on_cant_use_version(self): + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x02") + + self._expect_send(b"\x00\x02") + self._expect_recv(1, "\x01") + + self._test_security_handshake_fails() + + def test_security_handshake_fails_on_missing_subauth(self): + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x02") + + self._expect_send(b"\x00\x02") + self._expect_recv(1, "\x00") + + self._expect_recv(1, "\x01") + subtypes_raw = [auth.AuthVeNCryptSubtype.X509VNC] + subtypes = struct.pack('!I', *subtypes_raw) + self._expect_recv(4, subtypes) + + self._test_security_handshake_fails() + + def test_security_handshake_fails_on_auth_not_accepted(self): + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x02") + + self._expect_send(b"\x00\x02") + self._expect_recv(1, "\x00") + + self._expect_recv(1, "\x02") + subtypes_raw = [auth.AuthVeNCryptSubtype.X509NONE, + auth.AuthVeNCryptSubtype.X509VNC] + subtypes = struct.pack('!2I', *subtypes_raw) + self._expect_recv(8, subtypes) + + self._expect_send(struct.pack('!I', subtypes_raw[0])) + + self._expect_recv(1, "\x00") + + self._test_security_handshake_fails() + + @mock.patch.object(ssl, "wrap_socket") + def test_security_handshake_fails_on_ssl_failure(self, mock_socket): + self._expect_recv(1, "\x00") + self._expect_recv(1, "\x02") + + self._expect_send(b"\x00\x02") + self._expect_recv(1, "\x00") + + self._expect_recv(1, "\x02") + subtypes_raw = [auth.AuthVeNCryptSubtype.X509NONE, + auth.AuthVeNCryptSubtype.X509VNC] + subtypes = struct.pack('!2I', *subtypes_raw) + self._expect_recv(8, subtypes) + + self._expect_send(struct.pack('!I', subtypes_raw[0])) + + self._expect_recv(1, "\x01") + + mock_socket.side_effect = ssl.SSLError("cheese") + + self._test_security_handshake_fails() + + mock_socket.assert_called_once_with( + self.compute_sock, + keyfile=None, + certfile=None, + server_side=False, + cert_reqs=ssl.CERT_REQUIRED, + ca_certs='/certs/ca.pem' + ) + + def test_types(self): + scheme = authvencrypt.RFBAuthSchemeVeNCrypt() + + self.assertEqual(auth.AuthType.VENCRYPT, + scheme.security_type()) diff --git a/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml b/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml index 1d79f5836cfd..017bf386fd29 100644 --- a/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml +++ b/releasenotes/notes/websocket-proxy-to-host-security-c3eca0647b0cbc02.yaml @@ -6,3 +6,6 @@ features: *nova-novncproxy* server and the compute node VNC server. - ``auth_schemes`` + - ``vencrypt_client_key`` + - ``vencrypt_client_cert`` + - ``vencrypt_ca_certs``