diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fdd082d4..67856403 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,8 @@ jobs: docker pull hawkbit/hawkbit-update-server docker run -d --name hawkbit -p 8080:8080 hawkbit/hawkbit-update-server \ --hawkbit.server.security.dos.filter.enabled=false \ - --hawkbit.server.security.dos.maxStatusEntriesPerAction=-1 + --hawkbit.server.security.dos.maxStatusEntriesPerAction=-1 \ + --server.forward-headers-strategy=NATIVE - name: Run test suite run: | diff --git a/README.md b/README.md index 1331f7ca..a44f479f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Setup target (device) configuration file: target_name = test-target auth_token = bhVahL1Il1shie2aj2poojeChee6ahShu #gateway_token = bhVahL1Il1shie2aj2poojeChee6ahShu + #ssl_engine = pkcs11 + #ssl_key = pkcs11:token=mytoken;object=mykey + #ssl_cert = /path/to/certificate.pem bundle_download_location = /tmp/bundle.raucb retry_wait = 60 connect_timeout = 20 @@ -98,7 +101,7 @@ Test Suite Prepare test suite: ```shell -$ sudo apt install libgirepository1.0-dev nginx-full +$ sudo apt install libgirepository1.0-dev nginx-full libcairo2-dev $ python3 -m venv venv $ source venv/bin/activate (venv) $ pip install --upgrade pip @@ -111,7 +114,8 @@ Run hawkBit docker container: $ docker pull hawkbit/hawkbit-update-server $ docker run -d --name hawkbit -p 8080:8080 hawkbit/hawkbit-update-server \ --hawkbit.server.security.dos.filter.enabled=false \ - --hawkbit.server.security.dos.maxStatusEntriesPerAction=-1 + --hawkbit.server.security.dos.maxStatusEntriesPerAction=-1 \ + --server.forward-headers-strategy=NATIVE ``` Run test suite: diff --git a/config.conf.example b/config.conf.example index 42acfec7..8a855787 100644 --- a/config.conf.example +++ b/config.conf.example @@ -20,6 +20,11 @@ auth_token = cb115a721af28f781b493fa467819ef5 # Or gateway_token can be used instead of auth_token #gateway_token = cb115a721af28f781b493fa467819ef5 +# Or ssl key/cert locations if mTLS is used +#ssl_engine = pkcs11 +#ssl_key = pkcs11:token=mytoken;object=mykey +#ssl_cert = /path/to/certificate.pem + # Temporay file RAUC bundle should be downloaded to bundle_download_location = /tmp/bundle.raucb diff --git a/docs/using.rst b/docs/using.rst index 56a4a42d..2ef6da36 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -6,6 +6,9 @@ Using the RAUC hawkbit Updater Authentication -------------- +Target token +^^^^^^^^^^^^ + As described on the `hawkBit Authentication page `_ in the "DDI API Authentication Modes" section, a device can be authenticated with a security token. A security token can be either a "Target" token or a @@ -13,6 +16,9 @@ with a security token. A security token can be either a "Target" token or a defined in hawkBit. In the RAUC hawkBit updater's configuration file it's referred to as ``auth_token``. +Gateway token +^^^^^^^^^^^^^ + Targets can also be connected through a gateway which manages the targets directly and as a result these targets are indirectly connected to the hawkBit update server. The "Gateway" token is used to authenticate this gateway and @@ -24,6 +30,26 @@ Although gateway token is very handy during development or testing, it's recommended to use this token with care because it can be used to authenticate any device. +Mutual TLS with client key/certificate +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +HawkBit also offers a certificate-based authentication mechanism, also known +as mutual TLS (mTLS), which eliminates the need to share a security token with +the server. This is the preferred authentication mode targets connecting to +bosch-iot-suite.com. The target needs to send a complete (self-contained) +certificate chain along with the request which is then validated by a trusted +reverse proxy. The certificate chain can contain multiple certificates, +e.g. a target-specific client certificate, an intermediate certificate, and +a root certificate. A full certificate chain is required because the reverse +proxy only keeps fingerprints of issuer(s) certificates. +In the RAUC hawkBit updater's configuration file the options are called +``ssl_key`` and ``ssl_cert``. They need to be set to the target's private +key and a full certificate chain. If a file is supplied it needs to be in PEM +format. +Optionally, the ``ssl_engine`` option can be set if an openssl engine +needs to be loaded to access the private key. In that case the format of the +value supplied to ``ssl_key`` depends on the engine configured. + Streaming Support ----------------- diff --git a/include/config-file.h b/include/config-file.h index a2df87fe..8d8cf4fe 100644 --- a/include/config-file.h +++ b/include/config-file.h @@ -15,6 +15,9 @@ typedef struct Config_ { gchar* hawkbit_server; /**< hawkBit host or IP and port */ gboolean ssl; /**< use https or http */ gboolean ssl_verify; /**< verify https certificate */ + gchar* ssl_key; /**< SSL/TLS authentication private key */ + gchar* ssl_cert; /**< SSL/TLS client certificate */ + gchar* ssl_engine; /**< SSL engine to use with ssl_key */ gboolean post_update_reboot; /**< reboot system after successful update */ gboolean resume_downloads; /**< resume downloads or not */ gboolean stream_bundle; /**< streaming installation or not */ diff --git a/include/hawkbit-client.h b/include/hawkbit-client.h index 76d98218..1f2d60ce 100644 --- a/include/hawkbit-client.h +++ b/include/hawkbit-client.h @@ -102,6 +102,8 @@ struct on_new_software_userdata { GSourceFunc install_complete_callback; /**< callback function to be called when installation is complete */ gchar *file; /**< downloaded new software file */ gchar *auth_header; /**< authentication header for bundle streaming */ + gchar *ssl_key; /**< authentication key for bundle streaming */ + gchar *ssl_cert; /**< authentication certificate for bundle streaming */ gboolean ssl_verify; /**< whether to ignore server cert verification errors */ gboolean install_success; /**< whether the installation succeeded or not (only meaningful for run_once mode!) */ }; diff --git a/include/rauc-installer.h b/include/rauc-installer.h index f8415799..c6793a83 100644 --- a/include/rauc-installer.h +++ b/include/rauc-installer.h @@ -14,6 +14,8 @@ struct install_context { gchar *bundle; /**< Rauc bundle file to install */ gchar *auth_header; /**< Authentication header for bundle streaming */ + gchar *ssl_key; /**< SSL client authentication key */ + gchar *ssl_cert; /**< SSL client authentication certificate */ gboolean ssl_verify; /**< Whether to ignore server cert verification errors */ GSourceFunc notify_event; /**< Callback function */ GSourceFunc notify_complete; /**< Callback function */ @@ -31,6 +33,8 @@ struct install_context { * @param[in] bundle RAUC bundle file (.raucb) to install. * @param[in] auth_header Authentication header on HTTP streaming installation or NULL on normal * installation. + * @param[in] ssl_key Client authentication key or NULL on normal installation. + * @param[in] ssl_cert Client authentication certificate or NULL on normal installation. * @param[in] ssl_verify Whether to ignore server cert verification errors. * @param[in] on_install_notify Callback function to be called with status info during * installation. @@ -40,7 +44,8 @@ struct install_context { * @return for wait=TRUE, TRUE if installation succeeded, FALSE otherwise; for * wait=FALSE TRUE is always returned immediately */ -gboolean rauc_install(const gchar *bundle, const gchar *auth_header, gboolean ssl_verify, +gboolean rauc_install(const gchar *bundle, const gchar *auth_header, + gchar *ssl_key, gchar *ssl_cert, gboolean ssl_verify, GSourceFunc on_install_notify, GSourceFunc on_install_complete, gboolean wait); #endif // __RAUC_INSTALLER_H__ diff --git a/src/config-file.c b/src/config-file.c index d09aebca..778b5a30 100644 --- a/src/config-file.c +++ b/src/config-file.c @@ -241,6 +241,10 @@ Config* load_config_file(const gchar *config_file, GError **error) gboolean key_auth_token_exists = FALSE; gboolean key_gateway_token_exists = FALSE; gboolean bundle_location_given = FALSE; + gboolean ssl_key_exists = FALSE; + gboolean ssl_cert_exists = FALSE; + gboolean ssl_auth = FALSE; + gboolean token_auth = FALSE; g_return_val_if_fail(config_file, NULL); g_return_val_if_fail(error == NULL || *error == NULL, NULL); @@ -255,13 +259,43 @@ Config* load_config_file(const gchar *config_file, GError **error) error)) return NULL; + if (!get_key_bool(ini_file, "client", "ssl", &config->ssl, DEFAULT_SSL, error)) + return NULL; + if (!get_key_bool(ini_file, "client", "ssl_verify", &config->ssl_verify, + DEFAULT_SSL_VERIFY, error)) + return NULL; + if (config->ssl) { + ssl_key_exists = get_key_string(ini_file, "client", "ssl_key", + &config->ssl_key, NULL, NULL); + ssl_cert_exists = get_key_string(ini_file, "client", "ssl_cert", + &config->ssl_cert, NULL, NULL); + ssl_auth = ssl_cert_exists && ssl_key_exists; + if ((ssl_cert_exists || ssl_key_exists) && !ssl_auth) { + g_set_error(error, G_KEY_FILE_ERROR, G_KEY_FILE_ERROR_INVALID_VALUE, + "Only one of 'ssl_key' and 'ssl_cert' is set"); + return NULL; + } + get_key_string(ini_file, "client", "ssl_engine", + &config->ssl_engine, NULL, NULL); + if (config->ssl_engine && !ssl_auth) { + g_set_error(error, G_KEY_FILE_ERROR, G_KEY_FILE_ERROR_INVALID_VALUE, + "SSL engine set without ssl_key or ssl_cert"); + return NULL; + } + } key_auth_token_exists = get_key_string(ini_file, "client", "auth_token", &config->auth_token, NULL, NULL); key_gateway_token_exists = get_key_string(ini_file, "client", "gateway_token", &config->gateway_token, NULL, NULL); - if (!key_auth_token_exists && !key_gateway_token_exists) { + token_auth = key_auth_token_exists || key_gateway_token_exists; + if (!token_auth && !ssl_auth) { g_set_error(error, G_KEY_FILE_ERROR, G_KEY_FILE_ERROR_INVALID_VALUE, - "Neither 'auth_token' nor 'gateway_token' set"); + "Neither token nor ssl authentication set"); + return NULL; + } + if (token_auth && ssl_auth) { + g_set_error(error, G_KEY_FILE_ERROR, G_KEY_FILE_ERROR_INVALID_VALUE, + "Both token and ssl authentication set"); return NULL; } if (key_auth_token_exists && key_gateway_token_exists) { @@ -277,11 +311,6 @@ Config* load_config_file(const gchar *config_file, GError **error) return NULL; bundle_location_given = get_key_string(ini_file, "client", "bundle_download_location", &config->bundle_download_location, NULL, NULL); - if (!get_key_bool(ini_file, "client", "ssl", &config->ssl, DEFAULT_SSL, error)) - return NULL; - if (!get_key_bool(ini_file, "client", "ssl_verify", &config->ssl_verify, - DEFAULT_SSL_VERIFY, error)) - return NULL; if (!get_group(ini_file, "device", &config->device, error)) return NULL; if (!get_key_int(ini_file, "client", "connect_timeout", &config->connect_timeout, @@ -338,6 +367,9 @@ void config_file_free(Config *config) g_free(config->tenant_id); g_free(config->auth_token); g_free(config->gateway_token); + g_free(config->ssl_engine); + g_free(config->ssl_key); + g_free(config->ssl_cert); g_free(config->bundle_download_location); if (config->device) g_hash_table_destroy(config->device); diff --git a/src/hawkbit-client.c b/src/hawkbit-client.c index dca518bf..7a1bfec8 100644 --- a/src/hawkbit-client.c +++ b/src/hawkbit-client.c @@ -217,7 +217,7 @@ static char* get_auth_header() return g_strdup_printf("Authorization: GatewayToken %s", hawkbit_config->gateway_token); - g_return_val_if_reached(NULL); + return NULL; } /** @@ -242,6 +242,66 @@ static gboolean set_auth_curl_header(struct curl_slist **headers, GError **error return res; } +/** + * @brief Set Curl options for TLS/SSL client authentication + * + * @param[in] curl Curl handle + * @param[out] error Error + * @return TRUE if ssl authorization method set in config was set successfully, + * FALSE otherwise (error set) + */ +static gboolean set_auth_curl_ssl(CURL *curl, GError **error) +{ + curl_easy_setopt(curl, CURLOPT_SSLKEY, hawkbit_config->ssl_key); + curl_easy_setopt(curl, CURLOPT_SSLCERT, hawkbit_config->ssl_cert); + + if (hawkbit_config->ssl_engine) { + if (curl_easy_setopt(curl, CURLOPT_SSLENGINE, hawkbit_config->ssl_engine) != CURLE_OK) { + g_set_error(error, RHU_HAWKBIT_CLIENT_CURL_ERROR, + CURLE_FAILED_INIT, "Failed to set ssl engine"); + return FALSE; + } + curl_easy_setopt(curl, CURLOPT_SSLKEYTYPE, "ENG"); + if (curl_easy_setopt(curl, CURLOPT_SSLENGINE_DEFAULT, 1L) != CURLE_OK) { + g_set_error(error, RHU_HAWKBIT_CLIENT_CURL_ERROR, + CURLE_FAILED_INIT, "Failed to set engine as default"); + return FALSE; + } + g_debug("Using SSL engine %s", hawkbit_config->ssl_engine); + } + return TRUE; +} + +/** + * @brief Set Curl options for client authentication + * + * @param[in] curl Curl handle + * @param[out] headers curl_slist** of already set headers + * @param[out] error Error + * @return TRUE if authorization method set in config and header was added successfully, + * TRUE if no authorization method set, FALSE otherwise (error set) + */ +static gboolean set_auth_curl(CURL *curl, struct curl_slist **headers, GError **error) +{ + gboolean res; + + // Try ssl authentication + if (hawkbit_config->ssl_key && hawkbit_config->ssl_cert) { + res = set_auth_curl_ssl(curl, error); + if (res) { + g_debug("SSL authentication set"); + return TRUE; + } + } + + // Try token authentication + res = set_auth_curl_header(headers, error); + if (res) + g_debug("Token authentication set"); + + return res; +} + /** * @brief Set common Curl options, namely user agent, connect timeout, SSL * verify peer and SSL verify host options. @@ -314,7 +374,7 @@ static gboolean get_binary(const gchar *download_url, const gchar *file, curl_of curl_easy_setopt(curl, CURLOPT_RESUME_FROM_LARGE, resume_from); - if (!set_auth_curl_header(&headers, error)) + if (!set_auth_curl(curl, &headers, error)) return FALSE; // set up request headers @@ -434,7 +494,7 @@ static gboolean rest_request(enum HTTPMethod method, const gchar *url, if (!add_curl_header(&headers, "Accept: application/json;charset=UTF-8", error)) return FALSE; - if (!set_auth_curl_header(&headers, error)) + if (!set_auth_curl(curl, &headers, error)) return FALSE; if (jsonRequestBody && @@ -839,6 +899,8 @@ static gpointer download_thread(gpointer data) .install_complete_callback = install_complete_cb, .file = hawkbit_config->bundle_download_location, .auth_header = NULL, + .ssl_key = NULL, + .ssl_cert = NULL, .ssl_verify = hawkbit_config->ssl_verify, .install_success = FALSE, }; @@ -994,6 +1056,8 @@ static gboolean start_streaming_installation(Artifact *artifact, GError **error) .install_complete_callback = install_complete_cb, .file = artifact->download_url, .auth_header = auth_header, + .ssl_key = hawkbit_config->ssl_key, + .ssl_cert = hawkbit_config->ssl_cert, .ssl_verify = hawkbit_config->ssl_verify, .install_success = FALSE, }; @@ -1360,6 +1424,8 @@ static gboolean hawkbit_pull_cb(gpointer user_data) g_warning("Failed to authenticate. Check if auth_token is correct?"); if (hawkbit_config->gateway_token) g_warning("Failed to authenticate. Check if gateway_token is correct?"); + } else if (error->code == CURLE_SSL_CERTPROBLEM) { + g_warning("Failed to authenticate. Check if ssl_key/cert are correct?"); } else { g_warning("Scheduled check for new software failed: %s (%d)", error->message, error->code); diff --git a/src/rauc-hawkbit-updater.c b/src/rauc-hawkbit-updater.c index 3d65f4c3..efbf4c43 100644 --- a/src/rauc-hawkbit-updater.c +++ b/src/rauc-hawkbit-updater.c @@ -106,6 +106,7 @@ static gboolean on_new_software_ready_cb(gpointer data) notify_hawkbit_install_progress = userdata->install_progress_callback; notify_hawkbit_install_complete = userdata->install_complete_callback; userdata->install_success = rauc_install(userdata->file, userdata->auth_header, + userdata->ssl_key, userdata->ssl_cert, userdata->ssl_verify, on_rauc_install_progress_cb, on_rauc_install_complete_cb, run_once); diff --git a/src/rauc-installer.c b/src/rauc-installer.c index f9bcb949..5dacf2ce 100644 --- a/src/rauc-installer.c +++ b/src/rauc-installer.c @@ -147,11 +147,16 @@ static gpointer install_loop_thread(gpointer data) context = data; g_main_context_push_thread_default(context->loop_context); - if (context->auth_header) { - gchar *headers[2] = {NULL, NULL}; - headers[0] = context->auth_header; - g_variant_dict_insert(&args, "http-headers", "^as", headers); - + if (context->auth_header || (context->ssl_key && context->ssl_cert)) { + if (context->auth_header) { + gchar *headers[2] = {NULL, NULL}; + headers[0] = context->auth_header; + g_variant_dict_insert(&args, "http-headers", "^as", headers); + } + if (context->ssl_key && context->ssl_cert) { + g_variant_dict_insert(&args, "tls-key", "s", context->ssl_key); + g_variant_dict_insert(&args, "tls-cert", "s", context->ssl_cert); + } g_variant_dict_insert(&args, "tls-no-verify", "b", !context->ssl_verify); } @@ -200,7 +205,8 @@ static gpointer install_loop_thread(gpointer data) return NULL; } -gboolean rauc_install(const gchar *bundle, const gchar *auth_header, gboolean ssl_verify, +gboolean rauc_install(const gchar *bundle, const gchar *auth_header, + gchar *ssl_key, gchar *ssl_cert, gboolean ssl_verify, GSourceFunc on_install_notify, GSourceFunc on_install_complete, gboolean wait) { @@ -213,6 +219,8 @@ gboolean rauc_install(const gchar *bundle, const gchar *auth_header, gboolean ss context = install_context_new(); context->bundle = g_strdup(bundle); context->auth_header = g_strdup(auth_header); + context->ssl_key = ssl_key, + context->ssl_cert = ssl_cert, context->ssl_verify = ssl_verify; context->notify_event = on_install_notify; context->notify_complete = on_install_complete; diff --git a/test/conftest.py b/test/conftest.py index 38b7f418..4e0dea55 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -9,7 +9,7 @@ import pytest from hawkbit_mgmt import HawkbitMgmtTestClient, HawkbitError -from helper import run_pexpect, available_port +from helper import run_pexpect, available_port, run def pytest_addoption(parser): """Register custom argparse-style options.""" @@ -103,8 +103,11 @@ def _adjust_config(options={'client': {}}, remove={}, add_trailing_space=False): adjusted_config.set(section, key, value) # remove - for section, option in remove.items(): - adjusted_config.remove_option(section, option) + for section, options in remove.items(): + if isinstance(options, str): + options = [options] + for option in options: + adjusted_config.remove_option(section, option) # add trailing space if add_trailing_space: @@ -242,28 +245,72 @@ def nginx_config(tmp_path_factory): http {{ access_log /dev/null; - + map $ssl_client_s_dn $ssl_client_s_dn_cn {{ + default ""; + ~CN=(?[^,]+) $CN; + }} + {server} +}}""" + http_server = """ server {{ listen {port}; listen [::]:{port}; + {server_options} location / {{ proxy_pass http://localhost:8080; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Port {port}; {location_options} + }} + }}""" + mtls_server = """ + server {{ + listen {port} ssl; + listen [::]:{port} ssl; + + ssl_verify_client on; + ssl_verify_depth 3; + {server_options} + + location ~*/.*/controller/ {{ + proxy_pass http://localhost:8080; - # use proxy URL in JSON responses - sub_filter "localhost:$proxy_port/" "$host:$server_port/"; - sub_filter "$host:$proxy_port/" "$host:$server_port/"; - sub_filter_types application/json; - sub_filter_once off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Port {port}; + proxy_set_header X-Forwarded-Protocol https; + + proxy_set_header X-Ssl-Client-Cn $ssl_client_s_dn_cn; + + # These are required for clients to upload and download software. + proxy_request_buffering off; + client_max_body_size 1000m; + {location_options} }} - }} -}}""" + }}""" + - def _nginx_config(port, location_options): + def _to_nginx_option(option:dict): + if not option: + return "" + key_values = (f'{key} {value};' for key, value in option.items()) + return " ".join(key_values) + + def _nginx_config(port, location_options, server_options=None, mtls=False): + server_config = mtls_server if mtls else http_server + server_config_str = server_config.format( + port=port, location_options=_to_nginx_option(location_options), + server_options=_to_nginx_option(server_options)) proxy_config = tmp_path_factory.mktemp('nginx') / 'nginx.conf' - location_options = ( f'{key} {value};' for key, value in location_options.items()) - proxy_config_str = config_template.format(port=port, location_options=" ".join(location_options)) + proxy_config_str = config_template.format( + port=port, server=server_config_str) proxy_config.write_text(proxy_config_str) return proxy_config @@ -281,9 +328,9 @@ def nginx_proxy(nginx_config): procs = [] - def _nginx_proxy(options): + def _nginx_proxy(options, server_options=None, mtls=False): port = available_port() - proxy_config = nginx_config(port, options) + proxy_config = nginx_config(port, options, server_options, mtls) try: proc = run_pexpect(f'nginx -c {proxy_config} -p .', timeout=None) @@ -332,3 +379,57 @@ def partial_download_port(nginx_proxy): 'limit_rate': '70k', } return nginx_proxy(location_options) + +@pytest.fixture +def mtls_config(tmp_path_factory): + class MtlsConfig: + def __init__(self, pki_dir_location): + self.pki_dir= str(pki_dir_location) + "/pki/" + self.ca_cert= self.pki_dir + "root-ca.crt" + self.ca_key= self.pki_dir + "root-ca.key" + self.ca_csr= self.pki_dir + "root-csr.pem" + self.client_cert= self.pki_dir + "client.crt" + self.client_key= self.pki_dir + "client.key" + self.issuer_hash = self. pki_dir + "issuer_hash.txt" + + def client_cert_exist(self): + return os.path.isfile(self.client_cert) + + def ca_cert_exist(self): + return os.path.isfile(self.ca_cert) + + def get_issuer_hash(self): + return open(self.issuer_hash, "r").readline().strip() + + return MtlsConfig(tmp_path_factory.getbasetemp()) + +@pytest.fixture +def mtls_certificates(mtls_config, hawkbit_target_added): + """ + Generate CA cert and key if they don't exist and also generate specific client cert and key + for the Hawkbit controller id as Common Name. + """ + def _mtls_certificates(): + out, err, exitcode = run(f'{os.path.dirname(__file__)}/gen_pki.sh {mtls_config.pki_dir} {hawkbit_target_added}', timeout=20) + assert exitcode == 0 + assert mtls_config.ca_cert_exist() + assert mtls_config.client_cert_exist() + return mtls_config + return _mtls_certificates + +@pytest.fixture +def mtls_download_port(nginx_proxy, mtls_certificates): + """ + Runs an nginx proxy. HTTPS requests are forwarded to port 8080 + (default port of the docker hawkBit instance). Returns the port the proxy is running on. This + port can be set in the rauc-hawkbit-updater config to test partial downloads. + """ + mtls_cert = mtls_certificates() + hash_issuer = mtls_cert.get_issuer_hash() + location_options = {"proxy_set_header X-Ssl-Issuer-Hash-1": hash_issuer} + server_options = { + "ssl_certificate": mtls_cert.ca_cert, + "ssl_certificate_key": mtls_cert.ca_key, + "ssl_client_certificate": mtls_cert.ca_cert, + } + return nginx_proxy(location_options, server_options, mtls=True) diff --git a/test/gen_pki.sh b/test/gen_pki.sh new file mode 100755 index 00000000..a6c3a955 --- /dev/null +++ b/test/gen_pki.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e +CERT_DIR="${1}" +CONTROLLER_ID="${2}" +CA_CERT="root-ca.crt" +CA_KEY="root-ca.key" +CA_CSR="root-csr.pem" +CERT_CONFIG=' + [client] + basicConstraints = CA:FALSE + nsCertType = client, email + nsComment = "Local Test Client Certificate" + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid,issuer + keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment + extendedKeyUsage = clientAuth +' + +mkdir -p ${CERT_DIR} +cd ${CERT_DIR} +if [ ! -f "${CA_CERT}" ]; then + echo "Development CA" + openssl req -newkey rsa -keyout ${CA_KEY} -out ${CA_CSR} -subj "/O=Test/CN=localhost" --nodes + openssl req -x509 -sha256 -new -nodes -key ${CA_KEY} -days 3650 -out ${CA_CERT} -subj '/CN=localhost' +fi +if [ -n "${CONTROLLER_ID}" ]; then + openssl genrsa -out "client.key" 4096 + openssl req -new -key "client.key" -out "client.csr" -sha256 -subj "/CN=${CONTROLLER_ID}" + + openssl x509 -req -days 750 -in "client.csr" -sha256 -CA ${CA_CERT} -CAkey ${CA_KEY} -CAcreateserial -out "client.crt" -extensions client -extfile <(printf ${CERT_CONFIG}) + openssl x509 -in client.crt -issuer_hash -noout > issuer_hash.txt +fi diff --git a/test/rauc_dbus_dummy.py b/test/rauc_dbus_dummy.py index 4f949adc..63e52e58 100755 --- a/test/rauc_dbus_dummy.py +++ b/test/rauc_dbus_dummy.py @@ -103,7 +103,7 @@ def _get_bundle_sha1(bundle): return sha1.hexdigest() @staticmethod - def _get_http_bundle_sha1(url, auth_header): + def _get_http_bundle_sha1(url, auth_header, cert=None, verify=True): """Download file from URL using HTTP range requests and compute its sha1 checksum.""" sha1 = hashlib.sha1() headers = auth_header @@ -112,7 +112,7 @@ def _get_http_bundle_sha1(url, auth_header): offset = 0 while True: headers['Range'] = f'bytes={offset}-{offset + range_size - 1}' - r = requests.get(url, headers=headers) + r = requests.get(url, headers=headers, cert=cert, verify=verify) try: r.raise_for_status() sha1.update(r.content) @@ -130,20 +130,30 @@ def _check_install_requirements(self, source, args): Check that required headers are set, bundle is accessible (HTTP or locally) and its checksum matches. """ - if 'http-headers' in args: + if 'tls-key' in args and 'tls-cert' in args: + cert = (args['tls-cert'], args['tls-key']) + if 'tls-no-verify' in args and args['tls-no-verify']: + verify = False + elif 'tls-ca' in args: + verify = args['tls-ca'] + else: + verify = True + source_sha1 = self._get_http_bundle_sha1(source, {}, cert, verify) + + elif 'http-headers' in args: assert len(args['http-headers']) == 1 [auth_header] = args['http-headers'] key, value = auth_header.split(': ', maxsplit=1) - http_bundle_sha1 = self._get_http_bundle_sha1(source, {key: value}) - assert http_bundle_sha1 == self._get_bundle_sha1(self._bundle) + source_sha1 = self._get_http_bundle_sha1(source, {key: value}) # assume ssl_verify=false is set in test setup assert args['tls-no-verify'] is True - else: - # check bundle checksum matches expected checksum - assert self._get_bundle_sha1(source) == self._get_bundle_sha1(self._bundle) + source_sha1 = self._get_bundle_sha1(source) + + # check bundle checksum matches expected checksum + assert source_sha1 == self._get_bundle_sha1(self._bundle) @property def Operation(self): diff --git a/test/test_basics.py b/test/test_basics.py index f4024558..91e4608e 100644 --- a/test/test_basics.py +++ b/test/test_basics.py @@ -41,8 +41,8 @@ def test_config_file_non_existent(): assert out == '' assert err.strip() == 'No such configuration file: does-not-exist.conf' -def test_config_no_auth_token(adjust_config): - """Test config without auth_token option in client section.""" +def test_config_no_auth(adjust_config): + """Test config without authentication option in client section.""" config = adjust_config(remove={'client': 'auth_token'}) out, err, exitcode = run(f'rauc-hawkbit-updater -c "{config}" -r') @@ -50,9 +50,9 @@ def test_config_no_auth_token(adjust_config): assert exitcode == 4 assert out == '' assert err.strip() == \ - "Loading config file failed: Neither 'auth_token' nor 'gateway_token' set" + "Loading config file failed: Neither token nor ssl authentication set" -def test_config_multiple_auth_methods(adjust_config): +def test_config_multiple_token_auth_methods(adjust_config): """Test config with auth_token and gateway_token options in client section.""" config = adjust_config({'client': {'gateway_token': 'wrong-gateway-token'}}) @@ -63,6 +63,19 @@ def test_config_multiple_auth_methods(adjust_config): assert err.strip() == \ "Loading config file failed: Both 'auth_token' and 'gateway_token' set" +def test_config_multiple_auth_methods(adjust_config): + """Test config with both token and ssl auth options in client section.""" + config = adjust_config( + {'client': {'ssl': 'true', 'ssl_key': 'key', 'ssl_cert': 'cert'}} + ) + + out, err, exitcode = run(f'rauc-hawkbit-updater -c "{config}" -r') + + assert exitcode == 4 + assert out == '' + assert err.strip() == \ + "Loading config file failed: Both token and ssl authentication set" + def test_register_and_check_invalid_gateway_token(adjust_config): """Test config with invalid gateway_token.""" config = adjust_config( diff --git a/test/test_mtls.py b/test/test_mtls.py new file mode 100644 index 00000000..ab827ee9 --- /dev/null +++ b/test/test_mtls.py @@ -0,0 +1,40 @@ +import pytest + +from helper import run + +@pytest.mark.parametrize('mode', ('download','streaming')) +def test_install_success_mtls(hawkbit, adjust_config, bundle_assigned, + mtls_download_port, mtls_config, + rauc_dbus_install_success, mode): + """ + Assign bundle to target and test successful download and installation via MTLS. Make sure installation + result is received correctly by hawkBit. + """ + config = adjust_config( + {'client': { + 'hawkbit_server': f'localhost:{mtls_download_port}', + "ssl": "true", + "ssl_key": mtls_config.client_key, + "ssl_cert": mtls_config.client_cert, + "ssl_verify": "false", + 'stream_bundle': 'true' if mode == 'streaming' else "false"} + }, + remove={'client': ['bundle_download_location','auth_token']} if mode == 'streaming'else {'client':'auth_token'} + ) + + hawkbit.set_config("authentication.header.authority", mtls_config.get_issuer_hash()) + hawkbit.set_config("authentication.header.enabled",True) + + out, err, exitcode = run(f'rauc-hawkbit-updater -c "{config}" -r') + + assert 'New software ready for download' in out + + if mode == 'download': + assert 'Download complete' in out + + assert 'Software bundle installed successfully.' in out + assert err == '' + assert exitcode == 0 + + status = hawkbit.get_action_status() + assert status[0]['type'] == 'finished'