From 672206920240a0a5a55aa951fe5b58fe352d84fe Mon Sep 17 00:00:00 2001 From: Manoel Benedito Neto Date: Wed, 7 Feb 2024 12:29:20 -0300 Subject: [PATCH] OTS Token implementation for IPsec Auth This commit adds an implementation of OTS Token generation and validation for each new connection opened with IPsec Server. This aims to prevent the interception, modification or exchange of data with hosts different from the ones already valid and present on the system environment. - OTS Token has an expiry time of 5 seconds. - OTS Token has expired and used flags. - OTS Token must be validated between message exchanges with other IPsec Clients. - If any invalid procedure or validation occur, OTS Token must be destroyed along with the raise of an exception. Test Plan: PASS: Full build, system install, bootstrap and unlock DX system w/ unlocked enabled available status. PASS: Authentication and validation of new IPsec Client connection. PASS: ipsec-server service up and running in active status. Story: 2010940 Task: 49594 Signed-off-by: Manoel Benedito Neto Change-Id: I65303b9c6cd1d1e9c1c63ab6fcb18feeecf355e1 --- .../sysinv/sysinv/ipsec_auth/client/client.py | 6 +- .../sysinv/ipsec_auth/common/constants.py | 11 -- .../sysinv/ipsec_auth/common/objects.py | 122 ++++++++++++++++++ .../sysinv/sysinv/ipsec_auth/common/utils.py | 54 -------- .../sysinv/sysinv/ipsec_auth/server/server.py | 42 ++++-- 5 files changed, 153 insertions(+), 82 deletions(-) create mode 100644 sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/objects.py diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/client/client.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/client/client.py index 3c26626496..73487b73ee 100644 --- a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/client/client.py +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/client/client.py @@ -20,9 +20,9 @@ from cryptography.x509.oid import NameOID from oslo_log import log as logging from sysinv.ipsec_auth.client import config -from sysinv.ipsec_auth.common.constants import State from sysinv.ipsec_auth.common import constants from sysinv.ipsec_auth.common import utils +from sysinv.ipsec_auth.common.objects import State LOG = logging.getLogger(__name__) @@ -235,13 +235,13 @@ class Client(object): if not self._handle_rcvd_data(self.data): raise ConnectionAbortedError("Error receiving data from server") sel.modify(sock, selectors.EVENT_WRITE) - self.state = utils.get_next_state(self.state) + self.state = State.get_next_state(self.state) if mask & selectors.EVENT_WRITE: msg = self._handle_send_data(self.data) sock.sendall(bytes(msg, 'utf-8')) sel.modify(sock, selectors.EVENT_READ) - self.state = utils.get_next_state(self.state) + self.state = State.get_next_state(self.state) if self.state == State.STAGE_5: keep_running = False diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py index 3b2f9ac44e..c652942703 100644 --- a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/constants.py @@ -3,17 +3,6 @@ # # SPDX-License-Identifier: Apache-2.0 # -from enum import Enum - - -class State(Enum): - STAGE_1 = 1 - STAGE_2 = 2 - STAGE_3 = 3 - STAGE_4 = 4 - STAGE_5 = 5 - - PROCESS_ID = '/var/run/ipsec-server.pid' DEFAULT_BIND_ADDR = "0.0.0.0" diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/objects.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/objects.py new file mode 100644 index 0000000000..2c6342fb5e --- /dev/null +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/objects.py @@ -0,0 +1,122 @@ +# +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +import binascii +import enum +import random +import secrets +import string +import time +import threading + + +class State(enum.Enum): + STAGE_1 = 1 + STAGE_2 = 2 + STAGE_3 = 3 + STAGE_4 = 4 + STAGE_5 = 5 + + @staticmethod + def get_next_state(state): + '''Get the next IPsec Auth state whenever a Stage is finished. + + The IPsec Auth server-client interaction is separated into 5 work stages. + STAGE_1: represents the initial stage where IPsec Auth client send + the first message with OP code, mac address and a hash to + IPsec Auth server. + STAGE_2: represents the stage of validation of the message 1 received + from the client and generation of a response message. If the + validation is satisfied, the IPsec Auth server will encapsulate + an OTS Token, client's hostname, generated public key, + system-local-ca's certificate and a signed hash of this payload + in the response message to send it to the client. + STAGE_3: represents the stage of validation of the message 2 received + from the server and generation of a response message. if the + validation is satisfied, the IPsec Auth Client will encapsulate + an OTS Token, an encrypted Initial Vector (eiv), an encrypted + symetric key (eak1), an encrypted certificate request (eCSR) + and a signed hash of this payload in the response message to + send it to the server. + STAGE_4: represents the stage of validation of the message 3 from the + client and generation of a final response message. If the + validation of the message is satisfied, the IPsec Auth server + will create a CertificateRequest resource with a CSR received + from client's message and will encapsulate the signed + Certificate, network info and a signed hash of this payload in + the response message to send it to the client. + STAGE_5: represents the final stage of IPsec PKI Auth procedure and demands + that IPsec Auth server and client close the connection that + finished STAGE_4. + ''' + if state == State.STAGE_1: + state = State.STAGE_2 + elif state == State.STAGE_2: + state = State.STAGE_3 + elif state == State.STAGE_3: + state = State.STAGE_4 + elif state == State.STAGE_4: + state = State.STAGE_5 + return state + + +class Token(object): + VERSION = int(1).to_bytes(1, 'little') + EXPIRY_TIME = 5000 + + def __init__(self): + self.__nonce = secrets.token_bytes(16) # 128-bit nonce + self.__start_time = int(time.time() * 1000) # 64-bit utc time + self.__content = bytearray(self.VERSION + self.__nonce + + self.__start_time.to_bytes(8, 'little')) + self.__used = False + self.__expired = False + self.__timer = self.__set_timer() + + random.shuffle(self.__content) + + def __repr__(self): + return binascii.hexlify(self.__content).decode("utf-8") + + def __set_timer(self): + interval = self.EXPIRY_TIME / 1000 + timer = threading.Timer(interval, self.__expire_token) + timer.start() + return timer + + def __expire_token(self): + self.__expired = True + self.__timer.cancel() + return None + + def purge(self): + '''Purge the token.''' + self.__used = True + self.__expired = True + self.__content = bytearray() + + def set_as_used(self): + '''Set token as used.''' + self.__used = True + return None + + def get_content(self): + '''Returns token's content value.''' + return self.__content + + def is_valid(self) -> bool: + '''Verifies if token is valid per the evaluation of the expiration + time and its usage flag.''' + period = int(time.time() * 1000) - self.__start_time + if period >= self.EXPIRY_TIME and not self.__expired: + self.__expired = True + + return not (self.__expired or self.__used) + + def compare_tokens(self, token: str) -> bool: + '''Compares token's hex value with a hex string.''' + if len(token) > 0 and all(char in string.hexdigits for char in token): + return (repr(self) == token) + return False diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py index b25026fe66..75afce996e 100644 --- a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/common/utils.py @@ -5,17 +5,14 @@ # from sysinv.common import rest_api from sysinv.ipsec_auth.common import constants -from sysinv.ipsec_auth.common.constants import State from sysinv.common.kubernetes import KUBERNETES_ADMIN_CONF import base64 import fcntl import os -import secrets import socket import struct import subprocess -import time import yaml from cryptography import x509 @@ -36,48 +33,6 @@ from oslo_log import log as logging LOG = logging.getLogger(__name__) -def get_next_state(state): - '''Get the next IPsec Auth state whenever a Stage is finished. - - The IPsec Auth server-client interaction is separated into 5 work stages. - STAGE_1: represents the initial stage where IPsec Auth client send - the first message with OP code, mac address and a hash to - IPsec Auth server. - STAGE_2: represents the stage of validation of the message 1 received - from the client and generation of a response message. If the - validation is satisfied, the IPsec Auth server will encapsulate - an OTS Token, client's hostname, generated public key, - system-local-ca's certificate and a signed hash of this payload - in the response message to send it to the client. - STAGE_3: represents the stage of validation of the message 2 received - from the server and generation of a response message. if the - validation is satisfied, the IPsec Auth Client will encapsulate - an OTS Token, an encrypted Initial Vector (eiv), an encrypted - symetric key (eak1), an encrypted certificate request (eCSR) - and a signed hash of this payload in the response message to - send it to the server. - STAGE_4: represents the stage of validation of the message 3 from the - client and generation of a final response message. If the - validation of the message is satisfied, the IPsec Auth server - will create a CertificateRequest resource with a CSR received - from client's message and will encapsulate the signed - Certificate, network info and a signed hash of this payload in - the response message to send it to the client. - STAGE_5: represents the final stage of IPsec PKI Auth procedure and demands - that IPsec Auth server and client close the connection that - finished STAGE_4. - ''' - if state == State.STAGE_1: - state = State.STAGE_2 - elif state == State.STAGE_2: - state = State.STAGE_3 - elif state == State.STAGE_3: - state = State.STAGE_4 - elif state == State.STAGE_4: - state = State.STAGE_5 - return state - - def get_plataform_conf(param): value = None path = constants.PLATAFORM_CONF_FILE @@ -160,15 +115,6 @@ def save_data(path, data): f.write(data) -def generate_ots_token(): - format = "=b16sQ" # Token format: [b an integer][L unsigned long][L unsigned long] - version = 1 # version - nonce = secrets.token_bytes(16) # 128-bit nonce - utc_time = int(time.time() * 1000) # 64-bit utc time - - return struct.pack(format, version, nonce, utc_time) - - def symmetric_encrypt_data(binary_data, key): iv = os.urandom(16) diff --git a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py index 0da492de54..2d4c1d2f4b 100644 --- a/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py +++ b/sysinv/sysinv/sysinv/sysinv/ipsec_auth/server/server.py @@ -15,7 +15,8 @@ from sysinv.common import kubernetes from sysinv.common import rest_api from sysinv.ipsec_auth.common import constants from sysinv.ipsec_auth.common import utils -from sysinv.ipsec_auth.common.constants import State +from sysinv.ipsec_auth.common.objects import State +from sysinv.ipsec_auth.common.objects import Token from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization @@ -94,7 +95,7 @@ class IPsecConnection(object): self.signed_cert = None self.tmp_pub_key = None self.tmp_priv_key = None - self.ots_token = None + self.ots_token = Token() self.ca_key = self._get_system_local_ca_secret_info(self.CA_KEY) self.ca_crt = self._get_system_local_ca_secret_info(self.CA_CRT) self.state = State.STAGE_1 @@ -108,18 +109,21 @@ class IPsecConnection(object): if data: # A readable client socket has data LOG.debug("Received {!r}".format(data)) - self.state = utils.get_next_state(self.state) + self.state = State.get_next_state(self.state) LOG.debug("Preparing payload") msg = self._handle_write(data) sock.sendall(msg) - self.state = utils.get_next_state(self.state) + self.state = State.get_next_state(self.state) elif self.state == State.STAGE_5 or not data: + self.ots_token.purge() # Interpret empty result as closed connection LOG.info("Closing connection with {}".format(client_address)) sock.close() sel.unregister(sock) except Exception as e: # Interpret empty result as closed connection + if self.ots_token: + self.ots_token.purge() LOG.exception("%s" % (e)) LOG.error("Closing. {}".format(sock.getpeername())) sock.close() @@ -138,8 +142,8 @@ class IPsecConnection(object): if self.state == State.STAGE_2: LOG.info("Received IPSec Auth request") if not self._validate_client_connection(data): - msg = "Connection refused with client due to invalid info " \ - "received in payload." + msg = ("Connection refused with client due to invalid info " + "received in payload.") raise ConnectionRefusedError(msg) mac_addr = data["mac_addr"] @@ -149,10 +153,10 @@ class IPsecConnection(object): self.mgmt_subnet = client_data['mgmt_subnet'] pub_key = self._generate_tmp_key_pair() - self.ots_token = utils.generate_ots_token() - hash_payload = utils.hash_and_sign_payload(self.ca_key, self.ots_token + pub_key) + token = self.ots_token.get_content() + hash_payload = utils.hash_and_sign_payload(self.ca_key, token + pub_key) - payload["token"] = self.ots_token.hex() + payload["token"] = repr(self.ots_token) payload["hostname"] = self.hostname payload["pub_key"] = pub_key.decode("utf-8") payload["ca_cert"] = self.ca_crt.decode("utf-8") @@ -162,21 +166,31 @@ class IPsecConnection(object): if self.state == State.STAGE_4: LOG.info("Received IPSec Auth CSR request") + token = data["token"] eiv = base64.b64decode(data["eiv"]) eak1 = base64.b64decode(data['eak1']) ecsr = base64.b64decode(data['ecsr']) ehash = base64.b64decode(data['ehash']) + if self.ots_token.compare_tokens(token): + if self.ots_token.is_valid(): + self.ots_token.set_as_used() + else: + raise ValueError("Token expired or already used.") + else: + raise ValueError("Invalid token received.") + + token = self.ots_token.get_content() + + if not utils.verify_encrypted_hash(self.ca_key, ehash, + token, eak1, ecsr): + raise ValueError('Hash validation failed.') + iv = utils.asymmetric_decrypt_data(self.tmp_priv_key, eiv) aes_key = utils.asymmetric_decrypt_data(self.tmp_priv_key, eak1) cert_request = utils.symmetric_decrypt_data(aes_key, iv, ecsr) - if not utils.verify_encrypted_hash(self.ca_key, ehash, - self.ots_token, eak1, ecsr): - raise ValueError('Hash validation failed.') - self.signed_cert = self._sign_cert_request(cert_request) - if not self.signed_cert: raise ValueError('Unable to sign certificate request')