From 5532184ca83cf6ebb168724b58db8702b442167b Mon Sep 17 00:00:00 2001 From: Oaradla <339301037+Oaradla@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:08:24 +0000 Subject: [PATCH 1/2] feat(server): check box certificates Until now any client certificate whose common name looked like a box was accepted. Now: - Certificates issued by a TeddyCloud CA (TB1, TB2) are trusted. - Original Boxine certificates cannot be checked against a CA (the box only sends its own certificate, issued by "Boxine Factory SubCA nn", which is not available). If the original client.der of the box is uploaded, the box has to present exactly that certificate; without it, the first certificate a known box presents is pinned in toniebox.certPin (also with core.allowNewBox off, so nobody is locked out by the update). A box with a trusted certificate is pinned too, so no self-made certificate takes its place later. - core.boxCertAuth (default on) refuses boxes whose certificate does not match; off, they are only logged. - Result per box in internal.boxCertStatus and internal.boxCertSha256. - Same check for the MQTT server; a refused box gets CONNACK "not authorized". An unknown box id only counts as a box there while core.allowNewBox is on. The issuer check and the box id from the certificate subject are in box_cert.c, the check in box_cert_check.c. The TLS context keeps the SHA-256 fingerprint of the box certificate. Refs #85 --- Makefile | 4 +- include/box_cert.h | 11 ++ include/box_cert_check.h | 10 ++ include/settings.h | 3 + include/tls_adapter.h | 4 + include/tls_config.h | 4 +- src/box_cert.c | 28 +++++ src/box_cert_check.c | 68 ++++++++++++ src/mqtt_server.c | 89 +++++++++------- src/server.c | 47 +++------ src/settings.c | 3 + src/tls_adapter.c | 58 +++++++++++ tests/c/test_box_cert.c | 24 +++++ tests/c/test_runner.c | 2 + tests/c/tests.h | 2 + tests/py/test_box_cert_check.py | 179 ++++++++++++++++++++++++++++++++ 16 files changed, 467 insertions(+), 69 deletions(-) create mode 100644 include/box_cert.h create mode 100644 include/box_cert_check.h create mode 100644 src/box_cert.c create mode 100644 src/box_cert_check.c create mode 100644 tests/c/test_box_cert.c create mode 100644 tests/py/test_box_cert_check.py 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..8ce9a8fd3 --- /dev/null +++ b/tests/py/test_box_cert_check.py @@ -0,0 +1,179 @@ +""" +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 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, without_box_cert_auth): + original_box.request("GET", "/v1/time") + assert impostor.request("GET", "/v1/time")[0] == 200 + + +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() From 0c3b72d8948c273c9360be3022a6133ddbd21a88 Mon Sep 17 00:00:00 2001 From: SciLor Date: Fri, 16 Oct 2026 12:16:07 +0200 Subject: [PATCH 2/2] test: set core.boxCertAuth per box in the log-only test The global setting raced with the box overlay (401 instead of 200 in about 1 of 8 runs). Setting it on the overlay of the box is reliable (0 of 26 runs failed). --- tests/py/test_box_cert_check.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/py/test_box_cert_check.py b/tests/py/test_box_cert_check.py index 8ce9a8fd3..dee1f637f 100644 --- a/tests/py/test_box_cert_check.py +++ b/tests/py/test_box_cert_check.py @@ -11,6 +11,7 @@ import json import os import subprocess +import time import sys from pathlib import Path from urllib.parse import urlparse @@ -120,9 +121,19 @@ def test_box_with_teddycloud_certificate_is_pinned_too(tmp_path): impostor.close() -def test_other_certificate_is_only_logged_without_box_cert_auth(original_box, impostor, without_box_cert_auth): +def test_other_certificate_is_only_logged_without_box_cert_auth(original_box, impostor): original_box.request("GET", "/v1/time") - assert impostor.request("GET", "/v1/time")[0] == 200 + 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):