diff --git a/Makefile b/Makefile index 2f3530f9e..43c3075ab 100644 --- a/Makefile +++ b/Makefile @@ -811,7 +811,7 @@ TEST_C_SRC := $(wildcard tests/c/*.c) test_c: $(QUIET)$(ECHO) "[ ${CYAN}TEST${NC} ] Build and run C unit tests" $(QUIET)mkdir -p $(BIN_DIR) - $(QUIET)$(CC) -I include -I src/cyclone/common -I src/cyclone/cyclone_tcp -I cyclone/common -I cyclone/cyclone_tcp -I cyclone/cyclone_ssl -I cyclone/cyclone_crypto -D GPL_LICENSE_TERMS_ACCEPTED -ffunction-sections -Wl,--gc-sections -o $(BIN_DIR)/test_c $(TEST_C_SRC) $(SRC_DIR)/os_ext.c $(SRC_DIR)/str_ext.c $(SRC_DIR)/fs_ext.c $(SRC_DIR)/esp32_port.c $(SRC_DIR)/platform/platform_linux.c $(SRC_DIR)/cyclone/common/os_port_posix.c + $(QUIET)$(CC) -I include -I src/cyclone/common -I src/cyclone/cyclone_tcp -I cyclone/common -I cyclone/cyclone_tcp -I cyclone/cyclone_ssl -I cyclone/cyclone_crypto -D GPL_LICENSE_TERMS_ACCEPTED -ffunction-sections -Wl,--gc-sections -o $(BIN_DIR)/test_c $(TEST_C_SRC) $(SRC_DIR)/os_ext.c $(SRC_DIR)/str_ext.c $(SRC_DIR)/fs_ext.c $(SRC_DIR)/esp32_port.c $(SRC_DIR)/platform/platform_linux.c $(SRC_DIR)/cyclone/common/os_port_posix.c $(SRC_DIR)/box_cert.c $(QUIET)$(BIN_DIR)/test_c .PHONY: test @@ -820,7 +820,7 @@ test: test_c test_py test_auth_pool_reuse_with_server PYTHON ?= python3 HARDENING_TESTS := Cache499 Multipart502 Firmware503 Encode501 Tap495 Crawler508 KeyPermissions512 Secrets505 Traversal513 Range515 Extract522 PrivateFetch Settings528 # every entry gets a fresh sandboxed server (see tests/py/with_server.sh), a crash must only fail its own test -PY_TESTS := tests/py/test_tonies_custom_json_api.py tests/py/test_cors_preflight.py tests/py/test_web_legacy_gone.py tests/py/test_box_sim.py tests/py/test_box_profiles.py tests/py/test_box_cert_upload.py tests/py/test_cloud_proxy.py tests/py/test_taf_length.py tests/py/test_sse_keepalive.py tests/py/test_sse_join.py \ +PY_TESTS := tests/py/test_tonies_custom_json_api.py tests/py/test_cors_preflight.py tests/py/test_web_legacy_gone.py tests/py/test_box_sim.py tests/py/test_box_profiles.py tests/py/test_box_cert_check.py tests/py/test_box_cert_upload.py tests/py/test_cloud_proxy.py tests/py/test_taf_length.py tests/py/test_sse_keepalive.py tests/py/test_sse_join.py \ $(addprefix tests/py/test_hardening.py::,$(HARDENING_TESTS)) # make test_py TESTS=cors only entries containing "cors" diff --git a/include/box_cert.h b/include/box_cert.h new file mode 100644 index 000000000..115d43ed2 --- /dev/null +++ b/include/box_cert.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +#define BOX_CERT_ID_SIZE 13 + +/* Issuer or subject of a box certificate: Boxine/tonies or TeddyCloud. */ +bool box_cert_issuer_known(const char *issuer, const char *subject); + +/* The box id (MAC) from the certificate subject "b''" or "". */ +bool box_cert_id(const char *subject, char id[BOX_CERT_ID_SIZE]); diff --git a/include/box_cert_check.h b/include/box_cert_check.h new file mode 100644 index 000000000..615bfcc0d --- /dev/null +++ b/include/box_cert_check.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +#include "settings.h" +#include "tls.h" + +/* Checks the certificate of a box (core.boxCertAuth), pins it if needed and + * keeps the result in internal.boxCertStatus. False: refuse the box. */ +bool box_cert_accepted(const TlsContext *tls, settings_t *settings); diff --git a/include/settings.h b/include/settings.h index b02ff76db..209f2b861 100644 --- a/include/settings.h +++ b/include/settings.h @@ -158,6 +158,7 @@ typedef struct typedef struct { bool api_access; + char *certPin; bool overrideCloud; settings_box_generation boxGeneration; @@ -304,6 +305,8 @@ typedef struct time_t *last_ruid_time; char *ip; bool online; + char *boxCertStatus; + char *boxCertSha256; } settings_internal_t; typedef struct diff --git a/include/tls_adapter.h b/include/tls_adapter.h index a578a6346..6a243d65f 100644 --- a/include/tls_adapter.h +++ b/include/tls_adapter.h @@ -1,6 +1,7 @@ #ifndef __TLS_ADAPTER_H__ #define __TLS_ADAPTER_H__ +#include #include // for uint8_t #include "error.h" #include "rng/yarrow.h" // for YarrowContext @@ -16,4 +17,7 @@ extern YarrowContext yarrowContext; void tls_context_key_log_init(TlsContext *context); error_t load_cert(const char *dest_var, const char *src_file, const char *src_var, uint8_t settingsId); +/* SHA-256 (hex) and subject common name of a PEM certificate */ +bool tls_cert_fingerprint(const char_t *pem, char_t sha256[65], char_t *subject, size_t subjectSize); + #endif diff --git a/include/tls_config.h b/include/tls_config.h index 2feb7b1c5..0c8d355f5 100644 --- a/include/tls_config.h +++ b/include/tls_config.h @@ -37,7 +37,9 @@ #define TLS_PRIVATE_CONTEXT \ char client_cert_issuer[128]; \ char client_cert_subject[32]; \ - char client_cert_serial[64]; + char client_cert_serial[64]; \ + char client_cert_sha256[65]; \ + bool_t client_cert_trusted; // Desired trace level (for debugging purposes) #define TLS_TRACE_LEVEL TRACE_LEVEL_WARNING diff --git a/src/box_cert.c b/src/box_cert.c new file mode 100644 index 000000000..45524c44b --- /dev/null +++ b/src/box_cert.c @@ -0,0 +1,28 @@ +#include "box_cert.h" + +#include + +static bool contains(const char *text, const char *part) +{ + return strstr(text, part) != NULL; +} + +bool box_cert_issuer_known(const char *issuer, const char *subject) +{ + return contains(issuer, "Boxine Factory SubCA") || contains(issuer, "Toniebox SubCA") || + contains(issuer, "Toniebox Root CA") || contains(issuer, "TeddyCloud") || contains(subject, "TeddyCloud"); +} + +bool box_cert_id(const char *subject, char id[BOX_CERT_ID_SIZE]) +{ + size_t length = strlen(subject); + bool quoted = length == 15 && !strncmp(subject, "b'", 2) && subject[14] == '\''; + + if (!quoted && length != 12) + { + return false; + } + memcpy(id, quoted ? &subject[2] : subject, 12); + id[12] = '\0'; + return true; +} diff --git a/src/box_cert_check.c b/src/box_cert_check.c new file mode 100644 index 000000000..ae5db7e02 --- /dev/null +++ b/src/box_cert_check.c @@ -0,0 +1,68 @@ +#include "box_cert_check.h" + +#include "box_cert.h" +#include "debug.h" +#include "os_port.h" +#include "tls_adapter.h" + +/* The fingerprint of the client.der uploaded for this box, if it is the certificate of this box. */ +static bool uploaded_cert_pin(settings_t *settings, char_t pin[65]) +{ + char_t subject[32]; + char_t id[BOX_CERT_ID_SIZE]; + + return tls_cert_fingerprint(settings->internal.client.crt, pin, subject, sizeof(subject)) && + box_cert_id(subject, id) && osStrcasecmp(id, settings->commonName) == 0; +} + +static void pin_box_cert(settings_t *settings, const char_t *pin) +{ + TRACE_INFO("Box %s: certificate pinned, SHA256 %s\r\n", settings->commonName, pin); + settings_set_string_id("toniebox.certPin", pin, settings->internal.overlayNumber); + settings_save(); +} + +/* Only called for boxes TeddyCloud knows or accepts (core.allowNewBox). The uploaded + * client.der of the box is its real certificate; without one, the first certificate + * the box presents is pinned, also a trusted one, so no other certificate takes its place. */ +static const char *box_cert_status(const TlsContext *tls, settings_t *settings) +{ + char_t uploaded[65]; + + if (uploaded_cert_pin(settings, uploaded)) + { + if (osStrcasecmp(uploaded, settings->toniebox.certPin) != 0) + { + pin_box_cert(settings, uploaded); + } + } + else if (osStrlen(settings->toniebox.certPin) == 0) + { + pin_box_cert(settings, tls->client_cert_sha256); + } + if (tls->client_cert_trusted) + { + return "trusted"; + } + return osStrcasecmp(settings->toniebox.certPin, tls->client_cert_sha256) == 0 ? "pinned" : "mismatch"; +} + +bool box_cert_accepted(const TlsContext *tls, settings_t *settings) +{ + const char *status = box_cert_status(tls, settings); + bool verified = osStrcmp(status, "trusted") == 0 || osStrcmp(status, "pinned") == 0; + + if (osStrcmp(settings->internal.boxCertStatus, status) != 0) + { + if (!verified) + { + TRACE_WARNING("Box %s: certificate %s, SHA256 %s\r\n", settings->commonName, status, tls->client_cert_sha256); + } + settings_set_string_id("internal.boxCertStatus", status, settings->internal.overlayNumber); + } + if (osStrcmp(settings->internal.boxCertSha256, tls->client_cert_sha256) != 0) + { + settings_set_string_id("internal.boxCertSha256", tls->client_cert_sha256, settings->internal.overlayNumber); + } + return verified || !settings->core.boxCertAuth; +} diff --git a/src/mqtt_server.c b/src/mqtt_server.c index a46719eb1..d02d7c981 100644 --- a/src/mqtt_server.c +++ b/src/mqtt_server.c @@ -8,6 +8,8 @@ #include "debug.h" #include "settings.h" #include "mqtt_server.h" +#include "box_cert.h" +#include "box_cert_check.h" #include "tls.h" #include "rand.h" #include "tls_adapter.h" @@ -411,52 +413,67 @@ static void mqtt_server_publish(client_ctx_t *client_ctx, const char *topic, con } } -static void mqtt_connection_update_context_from_cert(MqttClientConnection *conn) +/* False when a box presents a certificate the box certificate check refuses. An unknown + * box only counts as a box while core.allowNewBox is on, like on the box port. */ +static bool mqtt_connection_update_context_from_cert(MqttClientConnection *conn) { - if (conn->tlsContext != NULL && osStrlen(conn->tlsContext->client_cert_subject) > 0) + if (conn->tlsContext == NULL) { - char_t *subject = conn->tlsContext->client_cert_subject; - char_t *issuer = conn->tlsContext->client_cert_issuer; + return true; + } - if (osStrstr(issuer, "Boxine Factory SubCA") != NULL || osStrstr(issuer, "Toniebox SubCA") != NULL - || osStrstr(issuer, "TeddyCloud") != NULL || osStrstr(subject, "TeddyCloud") != NULL || osStrstr(issuer, "Toniebox Root CA") != NULL) - { - char_t *commonName = NULL; - if (osStrlen(subject) == 15 && !osStrncmp(subject, "b'", 2) && subject[14] == '\'') // tonies standard cn with b'[MAC]' - { - commonName = strdup(&subject[2]); - commonName[osStrlen(commonName) - 1] = '\0'; - } else if (osStrlen(subject) == 12) { - commonName = strdup(subject); - } + char_t *subject = conn->tlsContext->client_cert_subject; + char_t *issuer = conn->tlsContext->client_cert_issuer; + char_t boxId[BOX_CERT_ID_SIZE]; - if (commonName != NULL) { - settings_t *box_settings = get_settings_cn(commonName); - if (box_settings != NULL && box_settings->internal.config_used) - { - conn->client_ctx.settings = box_settings; - conn->client_ctx.settingsNoOverlay = box_settings; - conn->client_ctx.state = get_toniebox_state_id(box_settings->internal.overlayNumber); - conn->client_ctx.state->box.id = conn->client_ctx.settings->commonName; - conn->client_ctx.state->box.name = conn->client_ctx.settings->boxName; - } - osFreeMem(commonName); - } - } + if (!box_cert_issuer_known(issuer, subject) || !box_cert_id(subject, boxId)) + { + return true; + } + + if (get_overlay_id(boxId) == 0 && !get_settings()->core.allowNewBox) + { + return true; + } + settings_t *box_settings = get_settings_cn(boxId); + if (box_settings == NULL || !box_settings->internal.config_used) + { + return true; + } + if (!box_cert_accepted(conn->tlsContext, box_settings)) + { + return false; } + + conn->client_ctx.settings = box_settings; + conn->client_ctx.settingsNoOverlay = box_settings; + conn->client_ctx.state = get_toniebox_state_id(box_settings->internal.overlayNumber); + conn->client_ctx.state->box.id = conn->client_ctx.settings->commonName; + conn->client_ctx.state->box.name = conn->client_ctx.settings->boxName; + return true; } +static error_t handle_mqtt_disconnect(MqttClientConnection *conn, MqttMessageType type, const char *topic, const uint8_t *payload, size_t payload_len); + static error_t handle_mqtt_connect(MqttClientConnection *conn, MqttMessageType type, const char *topic, const uint8_t *payload, size_t payload_len) { - mqtt_connection_update_context_from_cert(conn); - TRACE_INFO("MQTT: connection established for %s\r\n", conn->client_ctx.settings->commonName); - - uint8_t connack[] = {0x20, 0x02, 0x00, 0x00}; + bool accepted = mqtt_connection_update_context_from_cert(conn); + if (accepted) + { + TRACE_INFO("MQTT: connection established for %s\r\n", conn->client_ctx.settings->commonName); + } + + uint8_t connack[] = {0x20, 0x02, 0x00, accepted ? 0x00 : 0x05}; // 0x05: not authorized size_t written = 0; - if (conn->tlsContext) - return tlsWrite(conn->tlsContext, connack, sizeof(connack), &written, 0); - else - return socketSend(conn->socket, connack, sizeof(connack), &written, 0); + error_t error = conn->tlsContext ? tlsWrite(conn->tlsContext, connack, sizeof(connack), &written, 0) + : socketSend(conn->socket, connack, sizeof(connack), &written, 0); + if (!accepted) + { + TRACE_WARNING("MQTT: box certificate refused\r\n"); + handle_mqtt_disconnect(conn, MQTT_MSG_DISCONNECT, NULL, NULL, 0); + return ERROR_ACCESS_DENIED; + } + return error; } static error_t handle_mqtt_pingreq(MqttClientConnection *conn, MqttMessageType type, const char *topic, const uint8_t *payload, size_t payload_len) diff --git a/src/server.c b/src/server.c index f19b967c0..8900bfce3 100644 --- a/src/server.c +++ b/src/server.c @@ -34,6 +34,8 @@ #include "mqtt_server.h" // for mqtt_server_init, mqtt_server_task #include "stdbool.h" // for true, bool, false #include "tls.h" // for _TlsContext, tlsLoadCertificate +#include "box_cert.h" // for box_cert_id, box_cert_issuer_known +#include "box_cert_check.h" // for box_cert_accepted #include "tls_adapter.h" // for tls_context_key_log_init, tlsCache #include "toniebox_state.h" // for get_toniebox_state, get_toniebox_s... #include "toniebox_state_type.h" // for toniebox_state_box_t, toniebox_sta... @@ -341,6 +343,7 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, TRACE_DEBUG(" Issuer: '%s'\r\n", connection->tlsContext->client_cert_issuer); TRACE_DEBUG(" Subject: '%s'\r\n", connection->tlsContext->client_cert_subject); TRACE_DEBUG(" Serial: '%s'\r\n", connection->tlsContext->client_cert_serial); + TRACE_DEBUG(" SHA256: '%s'\r\n", connection->tlsContext->client_cert_sha256); request_source = connection->tlsContext->client_cert_subject; } else @@ -364,44 +367,28 @@ error_t httpServerRequestCallback(HttpConnection *connection, const char_t *uri, char_t *issuer = connection->tlsContext->client_cert_issuer; uint32_t boxGen = GENERATION_UNKNOWN; - if (osStrstr(issuer, "Boxine Factory SubCA") != NULL || osStrstr(issuer, "Toniebox SubCA") != NULL - || osStrstr(issuer, "TeddyCloud") != NULL || osStrstr(subject, "TeddyCloud") != NULL || osStrstr(issuer, "Toniebox Root CA") != NULL) + if (box_cert_issuer_known(issuer, subject)) { - char_t *commonName = NULL; - if (osStrlen(subject) == 15 && !osStrncmp(subject, "b'", 2) && subject[14] == '\'') // tonies standard cn with b'[MAC]' + char_t boxId[BOX_CERT_ID_SIZE]; + bool hasBoxId = box_cert_id(subject, boxId); + const char_t *commonName = hasBoxId ? boxId : subject; + + if (hasBoxId && get_overlay_id(commonName) == 0) { - commonName = strdup(&subject[2]); - commonName[osStrlen(commonName) - 1] = '\0'; - } else if (osStrlen(subject) == 12) { - commonName = strdup(subject); - } - if (commonName != NULL) { - if (get_overlay_id(commonName) == 0) + if (client_ctx->settings->core.allowNewBox) { - if (client_ctx->settings->core.allowNewBox) - { - TRACE_INFO("Added new client certificate with CN=%s\n", commonName); - } - else - { - TRACE_WARNING("Found unknown client certificate with CN=%s\n", commonName); - } + TRACE_INFO("Added new client certificate with CN=%s\n", commonName); } - if (get_overlay_id(commonName) > 0 || client_ctx->settings->core.allowNewBox) + else { - client_ctx->settings = get_settings_cn(commonName); - connection->private.authenticated = client_ctx->settings->toniebox.api_access; + TRACE_WARNING("Found unknown client certificate with CN=%s\n", commonName); } - osFreeMem(commonName); - // TODO: CHECK THE CERTIFICATES FOR REAL!!!!! } - else + if (get_overlay_id(commonName) > 0 || client_ctx->settings->core.allowNewBox) { - if (get_overlay_id(subject) > 0 || client_ctx->settings->core.allowNewBox) - { - client_ctx->settings = get_settings_cn(subject); - connection->private.authenticated = client_ctx->settings->toniebox.api_access; - } + client_ctx->settings = get_settings_cn(commonName); + connection->private.authenticated = client_ctx->settings->toniebox.api_access && + box_cert_accepted(connection->tlsContext, client_ctx->settings); } client_ctx->state = get_toniebox_state_id(client_ctx->settings->internal.overlayNumber); diff --git a/src/settings.c b/src/settings.c index 0f3ee4e83..a625cbac5 100644 --- a/src/settings.c +++ b/src/settings.c @@ -309,6 +309,8 @@ static void option_map_init(uint8_t settingsId) OPTION_INTERNAL_STRING("internal.toniebox_firmware.uaEsp32Firmware", &settings->internal.toniebox_firmware.uaEsp32Firmware, "", "Firmware version from User Agent (esp32)", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlVersion", &settings->internal.toniebox_firmware.rtnlVersion, "", "Firmware version from RTNL", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlFullVersion", &settings->internal.toniebox_firmware.rtnlFullVersion, "", "Firmware full version from RTNL", LEVEL_NONE) + OPTION_INTERNAL_STRING("internal.boxCertStatus", &settings->internal.boxCertStatus, "", "Result of the box certificate check", LEVEL_NONE) + OPTION_INTERNAL_STRING("internal.boxCertSha256", &settings->internal.boxCertSha256, "", "SHA-256 of the certificate the box presented last", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlDetail", &settings->internal.toniebox_firmware.rtnlDetail, "", "Firmware detail information", LEVEL_NONE) OPTION_INTERNAL_STRING("internal.toniebox_firmware.rtnlRegion", &settings->internal.toniebox_firmware.rtnlRegion, "", "Firmware region", LEVEL_NONE) OPTION_INTERNAL_UNSIGNED("internal.toniebox_firmware.otaVersionSfx", &settings->internal.toniebox_firmware.otaVersionSfx, 0, 0, UINT64_MAX, " ota version", LEVEL_NONE) @@ -379,6 +381,7 @@ static void option_map_init(uint8_t settingsId) OPTION_TREE_DESC("toniebox", "Toniebox", LEVEL_BASIC) OPTION_BOOL("toniebox.api_access", &settings->toniebox.api_access, TRUE, "API access", "Grant access to the API (default value for new boxes)", LEVEL_EXPERT) + OPTION_STRING("toniebox.certPin", &settings->toniebox.certPin, "", "Certificate pin", "SHA-256 of the certificate this box is known by. Empty: taken on the next connection, from the uploaded client.der of the box or from the box itself", LEVEL_EXPERT) OPTION_BOOL("toniebox.overrideCloud", &settings->toniebox.overrideCloud, TRUE, "Override cloud settings", "Override tonies cloud settings for the toniebox with those set here", LEVEL_BASIC) OPTION_UNSIGNED("toniebox.boxGeneration", &settings->toniebox.boxGeneration, GENERATION_UNKNOWN, GENERATION_UNKNOWN, GENERATION_TB2, "Box Generation", "Generation of the box (TB1/TB2)", LEVEL_EXPERT) // TB1 specific diff --git a/src/tls_adapter.c b/src/tls_adapter.c index b43a856a7..5f0253d21 100644 --- a/src/tls_adapter.c +++ b/src/tls_adapter.c @@ -15,8 +15,10 @@ #include "fs_ext.h" // for fsOpenFileEx #include "fs_port.h" // for FS_FILE_MODE_READ #include "fs_port_posix.h" // for fsReadFile, fsCloseFile, fsOpen... +#include "hash/sha256.h" // for sha256Compute #include "os_port.h" // for osFreeMem, osAllocMem, osMemset #include "pem_common.h" // for pemEncodeFile +#include "pkix/pem_import.h" // for pemImportCertificate #include "pkix/x509_cert_parse.h" // for x509ParseCertificate #include "pkix/x509_cert_validate.h" // for x509CheckNameConstraints, x509C... #include "settings.h" // for settings_get_string_id, setting... @@ -449,6 +451,29 @@ static void copyAsString(char *dst, size_t dstLen, size_t srcLen, const char_t * dst[len] = '\0'; } +/* Box certificates of TeddyCloud are issued directly by one of its CAs. */ +static bool issued_by(const X509CertInfo *cert, const char_t *caPem) +{ + size_t pemLength = caPem ? osStrlen(caPem) : 0; + size_t derLength = 0; + + if (pemLength == 0 || pemImportCertificate(caPem, pemLength, NULL, &derLength, NULL) != NO_ERROR) + { + return false; + } + + uint8_t *der = osAllocMem(derLength); + X509CertInfo *ca = osAllocMem(sizeof(X509CertInfo)); + bool issued = der != NULL && ca != NULL && + pemImportCertificate(caPem, pemLength, der, &derLength, NULL) == NO_ERROR && + x509ParseCertificate(der, derLength, ca) == NO_ERROR && + x509ValidateCertificate(cert, ca, 0) == NO_ERROR; + + osFreeMem(ca); + osFreeMem(der); + return issued; +} + static void copyAsHex(char *dst, size_t dstLen, size_t payloadLen, const uint8_t *payloadData) { if (payloadLen == 0 || dstLen == 0) @@ -472,6 +497,34 @@ static void copyAsHex(char *dst, size_t dstLen, size_t payloadLen, const uint8_t dst[maxHexBytes * 2] = '\0'; } +bool tls_cert_fingerprint(const char_t *pem, char_t sha256[65], char_t *subject, size_t subjectSize) +{ + size_t pemLength = pem ? osStrlen(pem) : 0; + size_t derLength = 0; + + if (pemLength == 0 || pemImportCertificate(pem, pemLength, NULL, &derLength, NULL) != NO_ERROR) + { + return false; + } + + uint8_t *der = osAllocMem(derLength); + X509CertInfo *cert = osAllocMem(sizeof(X509CertInfo)); + bool parsed = der != NULL && cert != NULL && + pemImportCertificate(pem, pemLength, der, &derLength, NULL) == NO_ERROR && + x509ParseCertificate(der, derLength, cert) == NO_ERROR; + if (parsed) + { + uint8_t digest[SHA256_DIGEST_SIZE]; + sha256Compute(der, derLength, digest); + copyAsHex(sha256, 65, sizeof(digest), digest); + copyAsString(subject, subjectSize, cert->tbsCert.subject.commonName.length, cert->tbsCert.subject.commonName.value); + } + + osFreeMem(cert); + osFreeMem(der); + return parsed; +} + /** * @brief Parse certificate chain * @param[in] context Pointer to the TLS context @@ -626,6 +679,11 @@ error_t tlsParseCertificateList(TlsContext *context, copyAsString(context->client_cert_issuer, sizeof(context->client_cert_issuer), certInfo->tbsCert.issuer.commonName.length, certInfo->tbsCert.issuer.commonName.value); copyAsString(context->client_cert_subject, sizeof(context->client_cert_subject), certInfo->tbsCert.subject.commonName.length, certInfo->tbsCert.subject.commonName.value); copyAsHex(context->client_cert_serial, sizeof(context->client_cert_serial), certInfo->tbsCert.serialNumber.length, certInfo->tbsCert.serialNumber.value); + uint8_t sha256[SHA256_DIGEST_SIZE]; + sha256Compute(p, n, sha256); + copyAsHex(context->client_cert_sha256, sizeof(context->client_cert_sha256), sizeof(sha256), sha256); + context->client_cert_trusted = issued_by(certInfo, settings_get_string("internal.server.ca")) || + issued_by(certInfo, settings_get_string("internal.server_tb2.ca")); } // Check if the end-user certificate can be matched with a trusted CA diff --git a/tests/c/test_box_cert.c b/tests/c/test_box_cert.c new file mode 100644 index 000000000..b940fdef7 --- /dev/null +++ b/tests/c/test_box_cert.c @@ -0,0 +1,24 @@ +#include +#include + +#include "box_cert.h" +#include "tests.h" + +void test_box_cert_id(void) +{ + char id[BOX_CERT_ID_SIZE]; + + assert(box_cert_id("b'aabbccddeeff'", id) && !strcmp(id, "aabbccddeeff")); + assert(box_cert_id("AABBCCDDEEFF", id) && !strcmp(id, "AABBCCDDEEFF")); + assert(!box_cert_id("b'aabbccddeeff", id)); + assert(!box_cert_id("TeddyCloud Server", id)); + assert(!box_cert_id("", id)); +} + +void test_box_cert_issuer_known(void) +{ + assert(box_cert_issuer_known("Boxine Factory SubCA 13", "b'aabbccddeeff'")); + assert(box_cert_issuer_known("TeddyCloud Root CA", "AABBCCDDEEFF")); + assert(box_cert_issuer_known("", "TeddyCloud")); + assert(!box_cert_issuer_known("Some CA", "b'aabbccddeeff'")); +} diff --git a/tests/c/test_runner.c b/tests/c/test_runner.c index 8805b80fc..d4ae2efb9 100644 --- a/tests/c/test_runner.c +++ b/tests/c/test_runner.c @@ -26,6 +26,8 @@ static const test_case_t tests[] = { {"esp32_port_patch", test_esp32_port_patch}, {"socket_receive_line_from_full_buffer", test_socket_receive_line_from_full_buffer}, {"socket_receive_line_capped_at_size", test_socket_receive_line_capped_at_size}, + {"box_cert_id", test_box_cert_id}, + {"box_cert_issuer_known", test_box_cert_issuer_known}, }; int main(void) diff --git a/tests/c/tests.h b/tests/c/tests.h index ca1cb4c28..f795c1c6b 100644 --- a/tests/c/tests.h +++ b/tests/c/tests.h @@ -12,3 +12,5 @@ void test_ipv4_is_public(void); void test_esp32_port_patch(void); void test_socket_receive_line_from_full_buffer(void); void test_socket_receive_line_capped_at_size(void); +void test_box_cert_id(void); +void test_box_cert_issuer_known(void); diff --git a/tests/py/test_box_cert_check.py b/tests/py/test_box_cert_check.py new file mode 100644 index 000000000..dee1f637f --- /dev/null +++ b/tests/py/test_box_cert_check.py @@ -0,0 +1,190 @@ +""" +Box certificate check (core.boxCertAuth): certificates issued by TeddyCloud are +trusted, other certificates are pinned per box (toniebox.certPin); a box with +another certificate than its pinned one is refused (only logged without +core.boxCertAuth). + + make test_py TESTS=box_cert_check +""" + +import http.client +import json +import os +import subprocess +import time +import sys +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from sim.box import Box # noqa: E402 + +BASE = urlparse(os.environ.get("TEDDYCLOUD_BASE_URL", "http://127.0.0.1:80")) +SANDBOX = os.environ.get("TC_SANDBOX") +HTTPS_API_PORT = int(os.environ.get("TEDDYCLOUD_HTTPS_API_PORT", "0")) + +pytestmark = pytest.mark.skipif(not SANDBOX or not HTTPS_API_PORT, reason="needs the sandbox from tests/py/with_server.sh") + + +def api(method, path, body=None): + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request(method, path, body) + resp = conn.getresponse() + return resp.status, resp.read().decode(errors="replace") + finally: + conn.close() + + +def overlay(box): + boxes = json.loads(api("GET", "/api/getBoxes")[1])["boxes"] + return next(b["ID"] for b in boxes if box.mac in b["commonName"].lower()) + + +def cert_status(box): + return api("GET", f"/api/settings/get/internal.boxCertStatus?overlay={overlay(box)}")[1] + + +def openssl(*args): + subprocess.run(["openssl", *args], check=True, capture_output=True) + + +@pytest.fixture(scope="module") +def teddycloud_box(): + box = Box("deadbeef0010", SANDBOX, BASE.hostname, HTTPS_API_PORT) + yield box + box.close() + + +def box_with_own_cert(directory, mac): + """A certificate like an original one: issued by a "Boxine Factory SubCA" TeddyCloud does not know.""" + openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "30", "-subj", "/CN=Boxine Factory SubCA 13", + "-keyout", directory / "ca.key", "-out", directory / "ca.pem") + openssl("req", "-newkey", "rsa:2048", "-nodes", "-subj", f"/CN=b'{mac}'", "-keyout", directory / "box.key", "-out", directory / "box.csr") + openssl("x509", "-req", "-in", directory / "box.csr", "-CA", directory / "ca.pem", "-CAkey", directory / "ca.key", + "-CAcreateserial", "-days", "30", "-out", directory / "box.pem") + box = Box(mac, SANDBOX, BASE.hostname, HTTPS_API_PORT) + box.cert, box.key = directory / "box.pem", directory / "box.key" + return box + + +@pytest.fixture(scope="module") +def original_box(tmp_path_factory): + box = box_with_own_cert(tmp_path_factory.mktemp("original"), "deadbeef0011") + yield box + box.close() + + +@pytest.fixture(scope="module") +def impostor(tmp_path_factory): + box = box_with_own_cert(tmp_path_factory.mktemp("impostor"), "deadbeef0011") + yield box + box.close() + + +@pytest.fixture +def without_box_cert_auth(): + assert api("POST", "/api/settings/set/core.boxCertAuth", "false")[0] == 200 + yield + api("POST", "/api/settings/set/core.boxCertAuth", "true") + + +def test_teddycloud_certificate_is_trusted(teddycloud_box): + assert teddycloud_box.request("GET", "/v1/time")[0] == 200 + assert cert_status(teddycloud_box) == "trusted" + + +def test_original_certificate_is_pinned_on_first_contact(original_box): + assert original_box.request("GET", "/v1/time")[0] == 200 + assert cert_status(original_box) == "pinned" + + +def test_other_certificate_of_a_pinned_box_is_refused(teddycloud_box, original_box, impostor): + original_box.request("GET", "/v1/time") + assert impostor.request("GET", "/v1/time")[0] == 401 + assert cert_status(impostor) == "mismatch" + assert original_box.request("GET", "/v1/time")[0] == 200 + assert teddycloud_box.request("GET", "/v1/time")[0] == 200 + + +def test_box_with_teddycloud_certificate_is_pinned_too(tmp_path): + box = Box("deadbeef0014", SANDBOX, BASE.hostname, HTTPS_API_PORT) + impostor = box_with_own_cert(tmp_path, "deadbeef0014") + try: + assert box.request("GET", "/v1/time")[0] == 200 + assert impostor.request("GET", "/v1/time")[0] == 401 + assert box.request("GET", "/v1/time")[0] == 200 + finally: + box.close() + impostor.close() + + +def test_other_certificate_is_only_logged_without_box_cert_auth(original_box, impostor): + original_box.request("GET", "/v1/time") + box_overlay = overlay(original_box) + assert api("POST", f"/api/settings/set/core.boxCertAuth?overlay={box_overlay}", "false")[0] == 200 + try: + # the settings are reloaded asynchronously from the changed file + for _ in range(50): + if api("GET", f"/api/settings/get/core.boxCertAuth?overlay={box_overlay}")[1].strip() == "false": + break + time.sleep(0.1) + assert impostor.request("GET", "/v1/time")[0] == 200 + finally: + api("POST", f"/api/settings/set/core.boxCertAuth?overlay={box_overlay}", "true") + + +def test_reset_pin_takes_the_next_certificate(original_box, impostor): + original_box.request("GET", "/v1/time") + assert api("POST", f"/api/settings/reset/toniebox.certPin?overlay={overlay(impostor)}")[0] == 200 + impostor.request("GET", "/v1/time") + assert cert_status(impostor) == "pinned" + original_box.request("GET", "/v1/time") + assert cert_status(original_box) == "mismatch" + + +def test_known_box_is_pinned_without_allow_new_box(tmp_path): + box = box_with_own_cert(tmp_path, "deadbeef0012") + box.request("GET", "/v1/time") + assert api("POST", f"/api/settings/reset/toniebox.certPin?overlay={overlay(box)}")[0] == 200 + assert api("POST", "/api/settings/set/core.allowNewBox", "false")[0] == 200 + try: + assert box.request("GET", "/v1/time")[0] == 200 + assert cert_status(box) == "pinned" + finally: + api("POST", "/api/settings/set/core.allowNewBox", "true") + box.close() + + +def upload_client_der(box, pem): + der = subprocess.run(["openssl", "x509", "-in", pem, "-outform", "der"], check=True, capture_output=True).stdout + boundary = "----boxcert" + body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"client.der\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\n").encode() + der + f"\r\n--{boundary}--\r\n".encode() + conn = http.client.HTTPConnection(BASE.hostname, BASE.port, timeout=10) + try: + conn.request("POST", f"/api/uploadCert?overlay={overlay(box)}", body, {"Content-Type": f"multipart/form-data; boundary={boundary}"}) + return conn.getresponse().status + finally: + conn.close() + + +def test_uploaded_client_der_is_the_real_certificate(tmp_path): + (tmp_path / "real").mkdir() + (tmp_path / "first").mkdir() + real = box_with_own_cert(tmp_path / "real", "deadbeef0013") + first = box_with_own_cert(tmp_path / "first", "deadbeef0013") + try: + first.request("GET", "/v1/time") + assert cert_status(first) == "pinned" + + assert upload_client_der(real, real.cert) == 200 + assert real.request("GET", "/v1/time")[0] == 200 + assert cert_status(real) == "pinned" + assert first.request("GET", "/v1/time")[0] == 401 + assert cert_status(first) == "mismatch" + finally: + real.close() + first.close()