TLS for the MQTT connection (iteration 2, step 5)

ca_file or the system's CAs (MOSQ_OPT_TLS_USE_OS_CERTS), client
certificate, broker certificate and host name verified, settings errors
reported before connecting. The client thread blocks SIGPIPE: with TLS,
a write to a connection the broker closed killed the process. The
reason of a failed handshake (libmosquitto's log) is appended to the
error. tst_mqtt covers TLS with test certificates in tests/data (D44).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:29:32 +02:00
co-authored by Claude Opus 5.5
parent 943e90c1b1
commit 148ae663cf
14 changed files with 336 additions and 16 deletions
+2
View File
@@ -55,4 +55,6 @@ add_test(NAME hadevice COMMAND tst_hadevice)
# MosquittoClient against a local mosquitto broker (D30 level 2); skips without one.
qt_add_executable(tst_mqtt tst_mqtt.cpp)
target_link_libraries(tst_mqtt PRIVATE frameassistant_lib Qt6::Test)
# TLS test certificates (D44).
target_compile_definitions(tst_mqtt PRIVATE TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data")
add_test(NAME mqtt COMMAND tst_mqtt)
+29
View File
@@ -0,0 +1,29 @@
# Test certificates
For the TLS cases in `tst_mqtt` (docs/DESIGN.md, D44). EC P-256, valid until 2126. Test use
only: the private keys are public.
| File | What |
|---|---|
| `ca.crt` | Test CA (its key was deleted after signing) |
| `server.crt`, `server.key` | Broker certificate from the test CA, for `localhost` and `127.0.0.1` |
| `client.crt`, `client.key` | Client certificate from the test CA |
| `other-ca.crt` | A second CA that signed nothing here: the wrong CA |
Made with:
```sh
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ca.key -out ca.crt -days 36500 -subj "/CN=FrameAssistant test CA"
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout server.key -out server.csr -subj "/CN=localhost"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt \
-days 36500 -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth")
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout client.key -out client.csr -subj "/CN=frameassistant-test-client"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt \
-days 36500 -extfile <(printf "extendedKeyUsage=clientAuth")
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout other-ca.key -out other-ca.crt -days 36500 -subj "/CN=Other test CA"
rm server.csr client.csr ca.srl ca.key other-ca.key
```
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBmTCCAT+gAwIBAgIUDYqH6JFwdNkUywBv5SyHxuuFc3owCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
NDBaGA8yMTI2MDkxMjE0MTg0MFowITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQg
dGVzdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEYTVW0Vs72geJmDYYTO
qCxisH73KxOpqQIFd8dZguzP3Ymu64T1xi6/7BbnPmtvKDP3KFDb7EzHTFQ9l32s
qWajUzBRMB0GA1UdDgQWBBRLda21NY4WNcdtMQUU20dpFjzLxTAfBgNVHSMEGDAW
gBRLda21NY4WNcdtMQUU20dpFjzLxTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49
BAMCA0gAMEUCIQCcWQf7cejgQw+xzZ/oDgUVSQ2s73wZ/N+b/XxD2goUswIgS/Y5
HeqcBO4HHqOVLbwRMhCWYbQlAhiLpaX26wyzIyM=
-----END CERTIFICATE-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBojCCAUegAwIBAgIUMl5Z4QsUdsD5cj2ZNcEWB1L4awAwCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
NDBaGA8yMTI2MDkxMjE0MTg0MFowJTEjMCEGA1UEAwwaZnJhbWVhc3Npc3RhbnQt
dGVzdC1jbGllbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQXzJWn1Vn4oghl
m077IKOEEX3VBFDMP7IGVBR6V19oHGgCbth9gEQXqPMr+8LYtK3UWYhk/VuFVP3X
abqlipIpo1cwVTATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUQjEJ7EHL
s4y78P14VB3keg8WCFIwHwYDVR0jBBgwFoAUS3WttTWOFjXHbTEFFNtHaRY8y8Uw
CgYIKoZIzj0EAwIDSQAwRgIhAJH+jXs/7SovwLZfT0BhlteFfCyhXuz6e39FbRjk
dkGmAiEA2IpZZlW+T3r71sHE+CJe8RpHdjDPjmij2nctps8Pdm4=
-----END CERTIFICATE-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggKrDK1CB3lOzi23P
XkvdV45GpZG4YMrAH0BVaB24liGhRANCAAQXzJWn1Vn4oghlm077IKOEEX3VBFDM
P7IGVBR6V19oHGgCbth9gEQXqPMr+8LYtK3UWYhk/VuFVP3XabqlipIp
-----END PRIVATE KEY-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBhzCCAS2gAwIBAgIUD73YYi99elTmyJ3drpvRqNus3McwCgYIKoZIzj0EAwIw
GDEWMBQGA1UEAwwNT3RoZXIgdGVzdCBDQTAgFw0yNjEwMDYxNDE4NDBaGA8yMTI2
MDkxMjE0MTg0MFowGDEWMBQGA1UEAwwNT3RoZXIgdGVzdCBDQTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABP6HT0Ey1ohM72ykQVIiNpd215blibaafbhlNbyyU/uD
4BCCuEWKkDF1wQwrLH/O+N8gjVUpymd8/dK9zGzjGPKjUzBRMB0GA1UdDgQWBBTB
dmiTSLe4jI9TKsp/+fFk/GoXWDAfBgNVHSMEGDAWgBTBdmiTSLe4jI9TKsp/+fFk
/GoXWDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQCZzxaVM3+1
OcS0fovnWedcs2s4QRAQ/PGzr1BGt5wxVwIgGxzzOfsh7yz8QveA0oUkPkOJLsOq
K739i67tvYbRNl4=
-----END CERTIFICATE-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBrDCCAVKgAwIBAgIUMl5Z4QsUdsD5cj2ZNcEWB1L4av8wCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
NDBaGA8yMTI2MDkxMjE0MTg0MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEkIJRd0MoLtpP0X755ZbjYvV0Vp2rDECQZyC6
4bbwufvk2vm1+JUBusnnnSIfqRU5S3wH6pzSyWxZiuc1TXscrqNzMHEwGgYDVR0R
BBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0GA1Ud
DgQWBBR7nqpN0AFa5jGDs9gq3vK3OvxpSTAfBgNVHSMEGDAWgBRLda21NY4WNcdt
MQUU20dpFjzLxTAKBggqhkjOPQQDAgNIADBFAiAwIGSpsMJmq3ExrtLq3PpNWbe/
HMnp7V7rmS8K8uWJpwIhAI8osCchJeKH7xg4yqfZXBxrU1FwagojEGQDk3HKEoIe
-----END CERTIFICATE-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgP5sIs6digdS0/HXU
a8G16oZk3HZwfYHTjnAIm0APWpuhRANCAASQglF3Qygu2k/RfvnlluNi9XRWnasM
QJBnILrhtvC5++Ta+bX4lQG6yeedIh+pFTlLfAfqnNLJbFmK5zVNexyu
-----END PRIVATE KEY-----
+144 -1
View File
@@ -1,6 +1,7 @@
// MosquittoClient against a real broker (docs/DESIGN.md, D30 level 2): a
// local mosquitto on a free port. Connect, messages, retained messages,
// reconnect, last will, login. Skips when mosquitto isn't installed.
// reconnect, last will, login, TLS (certificates in tests/data/, D44). Skips
// when mosquitto isn't installed.
//
// The binary is also its own helper process for the last-will test:
// `tst_mqtt --will-helper <port> <topic>` connects with a will and waits to be
@@ -24,6 +25,33 @@ namespace {
constexpr int kTimeoutMs = 10000;
QString data(const char* file) {
return QStringLiteral(TEST_DATA_DIR "/") + QLatin1String(file);
}
// Broker configuration for a TLS listener with the test CA's server
// certificate.
QString tlsListener(bool requireClientCertificate = false) {
return QStringLiteral(
"allow_anonymous true\ncafile %1\ncertfile %2\nkeyfile %3\n"
"require_certificate %4\n")
.arg(data("ca.crt"), data("server.crt"), data("server.key"),
requireClientCertificate ? QStringLiteral("true")
: QStringLiteral("false"));
}
MosquittoClient::Settings tlsSettings(int port, const char* caFile) {
MosquittoClient::Settings s;
s.broker.host = QStringLiteral("127.0.0.1");
s.broker.port = port;
s.broker.tls.enabled = true;
if (caFile) s.broker.tls.caFile = data(caFile);
s.clientId = QStringLiteral("tls");
s.reconnectMinMs = 100;
s.reconnectMaxMs = 500;
return s;
}
QString findTool(const QString& name) {
QString path = QStandardPaths::findExecutable(name);
if (path.isEmpty())
@@ -350,6 +378,121 @@ class TestMqtt : public QObject {
}
}
void tlsConnects() {
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener()));
MosquittoClient a(tlsSettings(broker.port(), "ca.crt"));
Recorder ra(&a);
QVERIFY(connectClient(a, ra, QStringLiteral("test/#")));
QVERIFY(a.publish(QStringLiteral("test/x"), "secret", false));
QTRY_COMPARE_WITH_TIMEOUT(ra.messages.size(), 1, kTimeoutMs);
QVERIFY(ra.errors.isEmpty());
}
// The broker's certificate isn't from the given CA: no connection, and
// the reason says why.
void tlsWrongCaRejected() {
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener()));
MosquittoClient a(tlsSettings(broker.port(), "other-ca.crt"));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY2(ra.errors.first().contains(QStringLiteral("verify failed")),
qPrintable(ra.errors.first()));
QTest::qWait(300);
QVERIFY(!ra.states.contains(MqttClient::State::Connected));
}
// Without ca_file the system's CAs are used (they don't know the test
// CA). Fails the same way, which shows that TLS is on.
void tlsWithSystemCertificates() {
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener()));
MosquittoClient a(tlsSettings(broker.port(), nullptr));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY2(ra.errors.first().contains(QStringLiteral("verify failed")),
qPrintable(ra.errors.first()));
}
// A TLS error ends libmosquitto's own loop for good (D45); ours retries.
// First a plain listener on the port (handshake fails), then TLS.
void retriesAfterTlsError() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient a(tlsSettings(broker.port(), "ca.crt"));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
broker.stop();
QVERIFY(broker.start(tlsListener()));
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Connected,
kTimeoutMs);
}
void tlsClientCertificate_data() {
QTest::addColumn<bool>("withCertificate");
QTest::newRow("with") << true;
QTest::newRow("without") << false;
}
void tlsClientCertificate() {
QFETCH(bool, withCertificate);
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener(true)));
MosquittoClient::Settings s = tlsSettings(broker.port(), "ca.crt");
if (withCertificate) {
s.broker.tls.certFile = data("client.crt");
s.broker.tls.keyFile = data("client.key");
}
MosquittoClient a(s);
Recorder ra(&a);
a.connectToBroker();
if (withCertificate) {
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Connected,
kTimeoutMs);
} else {
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY(!ra.states.contains(MqttClient::State::Connected));
}
}
// Settings errors show up at once, without a connection attempt.
void tlsSettingsErrors_data() {
QTest::addColumn<QString>("caFile");
QTest::addColumn<QString>("certFile");
QTest::addColumn<QString>("keyFile");
QTest::addColumn<QString>("error");
QTest::newRow("missing CA file")
<< QStringLiteral("/no/such/ca.crt") << QString() << QString()
<< QStringLiteral("TLS: cannot read /no/such/ca.crt");
QTest::newRow("certificate without key")
<< data("ca.crt") << data("client.crt") << QString()
<< QStringLiteral("TLS: cert_file and key_file go together");
QTest::newRow("client certificate without CA file")
<< QString() << data("client.crt") << data("client.key")
<< QStringLiteral("TLS: a client certificate needs ca_file");
}
void tlsSettingsErrors() {
QFETCH(QString, caFile);
QFETCH(QString, certFile);
QFETCH(QString, keyFile);
QFETCH(QString, error);
MosquittoClient::Settings s = tlsSettings(freePort(), nullptr);
s.broker.tls.caFile = caFile;
s.broker.tls.certFile = certFile;
s.broker.tls.keyFile = keyFile;
MosquittoClient a(s);
Recorder ra(&a);
a.connectToBroker();
QTRY_COMPARE_WITH_TIMEOUT(ra.errors.size(), 1, kTimeoutMs);
QCOMPARE(ra.errors.first(), error);
QCOMPARE(a.state(), MqttClient::State::Disconnected);
}
private:
QTemporaryDir m_dir;
};