QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
SigningController.cc
Go to the documentation of this file.
1#include "SigningController.h"
2
3#include <QtCore/QMetaEnum>
4#include <QtCore/QMetaObject>
5
6#include "MAVLinkLib.h"
7#include "MAVLinkSigning.h"
10#include "SecureMemory.h"
11
12QGC_LOGGING_CATEGORY(SigningControllerLog, "MAVLink.SigningController")
13
14namespace {
15
16const char* failReasonName(SigningController::FailReason r)
17{
18 return QMetaEnum::fromType<SigningFailure::Reason>().valueToKey(static_cast<int>(r));
19}
20
21} // namespace
22
24 : QObject(parent), _mavlinkChannel(channel)
25{
26 _timeout.setSingleShot(true);
27 connect(&_timeout, &QTimer::timeout, this, &SigningController::_onTimeout);
28 _wallClockRefresh.setInterval(kWallClockRefreshInterval);
29 connect(&_wallClockRefresh, &QTimer::timeout, this, [this]() { _channel.refreshOutgoingTimestamp(); });
30 qCDebug(SigningControllerLog) << "SigningController ctor — channel" << _mavlinkChannel;
31}
32
34{
35 qCDebug(SigningControllerLog) << "SigningController dtor — channel" << _mavlinkChannel;
36 _timeout.stop();
37 _wallClockRefresh.stop();
38 {
39 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
40 _autoDetectGuard.reset();
41 if (_op.kind == OpKind::Enable) {
42 QGC::secureZero(_op.keyBytes);
43 }
44 _op = PendingOp{};
45 }
46
47 // Detach status->signing before _channel dies — otherwise the next parser call dangles.
48 _channel.init(_mavlinkChannel, QByteArrayView(), nullptr);
49}
50
51void SigningController::_setOpLocked(PendingOp next)
52{
53 _op = std::move(next);
54 QMetaObject::invokeMethod(this, [this]() { emit stateChanged(); }, Qt::AutoConnection);
55}
56
57void SigningController::_setWallClockRefresh(bool on)
58{
59 // QTimer start/stop must run on the timer's owning (main) thread; auto-detect reaches here on the link-RX thread.
60 QMetaObject::invokeMethod(&_wallClockRefresh, on ? "start" : "stop", Qt::AutoConnection);
61}
62
64{
65 OpKind kind;
66 {
67 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
68 kind = _op.kind;
69 }
70 switch (kind) {
71 case OpKind::Enable:
72 return State::Enabling;
73 case OpKind::Disable:
74 return State::Disabling;
75 case OpKind::None:
76 break;
77 }
78 return _channel.isEnabled() ? State::On : State::Off;
79}
80
82{
84 s.state = state();
85 // Enabling installs the channel struct but isn't surfaced as enabled; Disabling stays enabled until vehicle
86 // confirms.
87 s.enabled = (s.state == State::On) || (s.state == State::Disabling);
88 s.keyName = _channel.keyHint();
90 s.streamCount = _channel.streamCount();
91 return s;
92}
93
95{
96 // Excludes Enabling: channel struct installed for verify but vehicle hasn't confirmed yet.
97 const State s = state();
98 return (s == State::On) || (s == State::Disabling);
99}
100
102{
103 return _channel.keyHint();
104}
105
107{
108 const State s = state();
109 if (s == State::Enabling) {
110 return tr("Configuring…");
111 }
112 if (s == State::Disabling) {
113 return tr("Disabling…");
114 }
115 if (!_channel.isEnabled()) {
116 return tr("Off");
117 }
118 // Last wire-status detail (empty when NONE); fall back to "On" for a healthy-but-idle link.
119 const QString detail = MAVLinkSigning::signingStatusString(_mavlinkChannel);
120 return detail.isEmpty() ? tr("On") : detail;
121}
122
124{
125 if (_channel.isEnabled()) {
126 const auto snap = _channel.currentTimestampAndName();
127 if (!snap.keyName.isEmpty() && snap.timestamp > 0) {
128 MAVLinkSigningKeys::instance()->recordTimestamp(snap.keyName, snap.timestamp);
129 }
130 }
131 const bool ok = _channel.init(_mavlinkChannel, QByteArrayView(), nullptr);
132 _channel.clearDetectCooldown();
133 _setWallClockRefresh(false);
134 return ok;
135}
136
138 const QString& keyNameHint)
139{
140 const uint64_t persisted = keyNameHint.isEmpty() ? 0 : MAVLinkSigningKeys::instance()->lastTimestamp(keyNameHint);
141 const bool ok = _channel.init(_mavlinkChannel, key, MAVLinkSigning::callbackForPolicy(policy), persisted, keyNameHint);
142 _setWallClockRefresh(_channel.isEnabled());
143 return ok;
144}
145
146std::optional<SigningFailure> SigningController::tryBeginEnable(uint8_t expectedSysId, const QString& kName,
147 const MAVLinkSigning::SigningKey& keyBytes)
148{
149 {
150 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
151 if (_isPendingLocked()) {
152 qCWarning(SigningControllerLog)
153 << "[ch" << _mavlinkChannel << "] enable rejected: operation already pending";
154 return SigningFailure{FailReason::VehicleUnreachable, tr("Signing operation already pending")};
155 }
156
157 // PendingEnableVerifyOnly: signOutgoing=false, lenient policy keeps non-responding vehicle observable while lib
158 // verifies its signed reply.
159 const QByteArrayView keyView(reinterpret_cast<const char*>(keyBytes.data()), keyBytes.size());
160 const uint64_t persisted = kName.isEmpty() ? 0 : MAVLinkSigningKeys::instance()->lastTimestamp(kName);
161 if (!_channel.init(_mavlinkChannel, keyView,
163 persisted, kName, /*signOutgoing=*/false)) {
164 qCWarning(SigningControllerLog) << "[ch" << _mavlinkChannel << "] enable rejected: signing init failed";
165 return SigningFailure{FailReason::InitFailed, tr("Failed to install signing for pending verification")};
166 }
167
168 _autoDetectGuard.emplace(_channel.suspendAutoDetect());
169 _setOpLocked(PendingOp{
170 .kind = OpKind::Enable,
171 .expectedSysId = expectedSysId,
172 .keyName = kName,
173 .keyBytes = keyBytes,
174 });
175 }
176 // QTimer is owned by main thread; tryBeginEnable is called on the main thread.
177 _timeout.setInterval(_effectiveTimeout());
178 _timeout.start();
179 _setWallClockRefresh(true); // channel installed (signOutgoing flips true on confirm); keep timestamp fresh meanwhile
180 qCDebug(SigningControllerLog) << "[ch" << _mavlinkChannel << "] enable pending — key" << kName << "sysid"
181 << expectedSysId << "timeout" << kTimeout << "ms";
182 return std::nullopt;
183}
184
185std::optional<SigningFailure> SigningController::tryBeginDisable(uint8_t expectedSysId)
186{
187 {
188 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
189 if (_isPendingLocked()) {
190 qCWarning(SigningControllerLog)
191 << "[ch" << _mavlinkChannel << "] disable rejected: operation already pending";
192 return SigningFailure{FailReason::VehicleUnreachable, tr("Signing operation already pending")};
193 }
194
195 if (!_channel.setAcceptUnsignedCallback(
197 qCWarning(SigningControllerLog) << "[ch" << _mavlinkChannel << "] disable rejected: channel not signing";
198 return SigningFailure{FailReason::VehicleUnreachable, tr("Channel not signing — cannot disable")};
199 }
200
201 _setOpLocked(PendingOp{
202 .kind = OpKind::Disable,
203 .expectedSysId = expectedSysId,
204 .keyName = {},
205 .keyBytes = {},
206 });
207 }
208 _timeout.setInterval(_effectiveTimeout());
209 _timeout.start();
210 qCDebug(SigningControllerLog) << "[ch" << _mavlinkChannel << "] disable pending — sysid" << expectedSysId
211 << "timeout" << kTimeout << "ms";
212 return std::nullopt;
213}
214
215bool SigningController::processFrame(bool framingOk, const mavlink_message_t& message)
216{
217 if (_channel.consumeStatusTransition(_mavlinkChannel)) {
218 QMetaObject::invokeMethod(this, [this]() { emit stateChanged(); }, Qt::AutoConnection);
219 }
220
221 if (!framingOk) {
222 MAVLinkSigning::logSigningFailure(_mavlinkChannel);
223 bool burstFired = false;
224 uint8_t burstCount = 0;
225 bool pendingEnable = false;
226 {
227 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
228 burstFired = _badSigBurst.record();
229 burstCount = _badSigBurst.count();
230 pendingEnable = (_op.kind == OpKind::Enable);
231 }
232 if (burstFired) {
233 // Pending-enable burst: user is staring at "Configuring…"; tell them it's a key mismatch.
234 const QString detail =
235 pendingEnable
236 ? tr("MAVLink signing: %1 consecutive bad signatures while enabling — the chosen key likely "
237 "does not match the vehicle's stored key. Verify the key on the vehicle, then retry.")
238 .arg(burstCount)
239 : tr("MAVLink signing: %1 consecutive bad signatures on this link — wrong key or vehicle clock "
240 "drift")
241 .arg(burstCount);
242 QMetaObject::invokeMethod(this, [this, detail]() { emit alertRaised(detail); }, Qt::AutoConnection);
243 }
244 return false;
245 }
246
247 {
248 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
249 _badSigBurst.reset();
250 }
251
252 bool autoDetected = false;
253 if (MAVLinkSigning::isMessageSigned(message) && !_channel.isEnabled()) {
254 // Auto-detect bypasses the deferred-confirm FSM intentionally: tryDetectKey verifies the HMAC first, so the
255 // vehicle is provably already signing with this key.
256 const QString detected = MAVLinkSigningKeys::instance()->tryDetectKey(this, message);
257 if (!detected.isEmpty()) {
258 QMetaObject::invokeMethod(this, [this, detected]() { emit keyAutoDetected(detected); }, Qt::AutoConnection);
259 autoDetected = true;
260 }
261 }
262
263 {
264 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
265 _handleFsmFrameLocked(message);
266 }
267
268 return autoDetected;
269}
270
272{
273 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
274 _badSigBurst.reset();
275}
276
277void SigningController::_handleFsmFrameLocked(const mavlink_message_t& message)
278{
279 if (!_isPendingLocked()) {
280 return;
281 }
282 if (message.sysid != _op.expectedSysId) {
283 return;
284 }
285
286 // ERROR-severity STATUSTEXT mentioning "signing" → fast-fail (e.g. ArduPilot armed-rejection) before 5s timeout.
287 if (message.msgid == MAVLINK_MSG_ID_STATUSTEXT) {
288 mavlink_statustext_t st;
289 mavlink_msg_statustext_decode(&message, &st);
290 const QString text = QString::fromLatin1(st.text, qstrnlen(st.text, sizeof(st.text)));
291 const bool errorSeverity = (st.severity <= MAV_SEVERITY_ERROR);
292 const bool mentionsSigning = text.contains(QLatin1String("signing"), Qt::CaseInsensitive);
293 if (errorSeverity && mentionsSigning) {
294 _failLocked(FailReason::InitFailed, tr("Vehicle rejected signing change: %1").arg(text));
295 return;
296 }
297 }
298
299 if (_op.kind == OpKind::Enable) {
300 if (message.msgid == MAVLINK_MSG_ID_HEARTBEAT && MAVLinkSigning::isMessageSigned(message)) {
301 // Defence-in-depth: re-verify against committed key so a future framing-error refactor can't confirm on the
302 // wrong key.
303 if (!MAVLinkSigning::verifySignature(_op.keyBytes, message)) {
304 qCWarning(SigningControllerLog)
305 << "[ch" << _mavlinkChannel
306 << "] pending-enable HEARTBEAT signature did not verify against committed key";
307 return;
308 }
309 _confirmLocked();
310 }
311 } else if (_op.kind == OpKind::Disable) {
312 if (!MAVLinkSigning::isMessageSigned(message) && message.msgid == MAVLINK_MSG_ID_HEARTBEAT) {
313 _op.unsignedSeen = true;
314 _confirmLocked();
315 }
316 }
317}
318
319void SigningController::_confirmLocked()
320{
321 // QTimer::stop is not thread-safe; route through queued invoke since processFrame may run on link thread.
322 QMetaObject::invokeMethod(&_timeout, "stop", Qt::AutoConnection);
323
324 if (_op.kind == OpKind::Enable) {
325 const QString confirmedName = _op.keyName;
326 if (!_channel.setSignOutgoing(true) || !_channel.setAcceptUnsignedCallback(MAVLinkSigning::callbackForPolicy(
328 const QString detail = tr("Signing confirmation received but local activation failed");
329 qCCritical(SigningControllerLog) << "[ch" << _mavlinkChannel << "]" << detail;
330 _failLocked(FailReason::InitFailed, detail);
331 return;
332 }
333 qCDebug(SigningControllerLog) << "[ch" << _mavlinkChannel << "] enable confirmed — key" << confirmedName;
334 _clearLocked();
335 QMetaObject::invokeMethod(
336 this, [this, confirmedName]() { emit signingConfirmed(confirmedName); }, Qt::AutoConnection);
337 } else if (_op.kind == OpKind::Disable) {
338 qCDebug(SigningControllerLog) << "[ch" << _mavlinkChannel << "] disable confirmed";
339 _completeDisableSuccessLocked();
340 }
341}
342
343void SigningController::_completeDisableSuccessLocked()
344{
345 _channel.init(_mavlinkChannel, QByteArrayView(), nullptr);
346 _clearLocked();
347 QMetaObject::invokeMethod(this, [this]() { emit signingConfirmed(QString{}); }, Qt::AutoConnection);
348}
349
350void SigningController::_failLocked(FailReason reason, const QString& detail, bool cancelled)
351{
352 QMetaObject::invokeMethod(&_timeout, "stop", Qt::AutoConnection);
353
354 // Disable: if vehicle already sent an unsigned heartbeat before timeout/cancel, treat as success.
355 if (_op.kind == OpKind::Disable && _op.unsignedSeen && !cancelled) {
356 _completeDisableSuccessLocked();
357 return;
358 }
359
360 FailReason effectiveReason = reason;
361 QString effectiveDetail = detail;
362
363 if (_op.kind == OpKind::Enable) {
364 _channel.init(_mavlinkChannel, QByteArrayView(), nullptr);
365 } else if (_op.kind == OpKind::Disable) {
368 effectiveReason = FailReason::VehicleUnreachable;
369 effectiveDetail =
370 tr("Signing disable not confirmed — vehicle is unreachable or still requires signed messages. Local "
371 "signing remains enabled.");
372 }
373
374 qCWarning(SigningControllerLog) << "[ch" << _mavlinkChannel
375 << "] signing operation failed:" << failReasonName(effectiveReason)
376 << effectiveDetail;
377
378 SigningFailure failure{effectiveReason, effectiveDetail};
379 _clearLocked();
380 QMetaObject::invokeMethod(this, [this, failure]() { emit signingFailed(failure); }, Qt::AutoConnection);
381}
382
383void SigningController::cancelPending(const QString& detail)
384{
385 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
386 if (!_isPendingLocked()) {
387 return;
388 }
389 const QString effectiveDetail =
390 detail.isEmpty()
391 ? tr("Signing operation cancelled — primary link changed before vehicle confirmation")
392 : detail;
393 _failLocked(FailReason::VehicleUnreachable, effectiveDetail, /*cancelled=*/true);
394}
395
396void SigningController::_onTimeout()
397{
398 QMutexLocker<QRecursiveMutex> locker(&_fsmMutex);
399 if (!_isPendingLocked()) {
400 return;
401 }
402 qCDebug(SigningControllerLog) << "[ch" << _mavlinkChannel << "] timeout fired —"
403 << (_op.kind == OpKind::Enable ? "enable" : "disable") << "not confirmed";
404 const QString detail = (_op.kind == OpKind::Enable) ? tr("Signing setup not confirmed by vehicle (timeout)")
405 : tr("Signing disable not confirmed by vehicle (timeout)");
406 _failLocked(FailReason::Timeout, detail);
407}
408
409void SigningController::_clearLocked()
410{
411 _autoDetectGuard.reset();
412 QGC::secureZero(_op.keyBytes);
413 _setOpLocked(PendingOp{});
414 // Common FSM exit: enable-confirm leaves the channel signing (keep), disable/abort disables it (stop).
415 _setWallClockRefresh(_channel.isEnabled());
416}
mavlink_channel_t
struct __mavlink_message mavlink_message_t
#define QGC_LOGGING_CATEGORY(name, categoryStr)
static MAVLinkSigningKeys * instance()
void recordTimestamp(const QString &name, uint64_t ts)
Update in-memory + persisted last-timestamp for name. Monotonic — older values are dropped.
QString tryDetectKey(SigningController *controller, const mavlink_message_t &message)
Try every stored key against message's signature; on match, configures channel and returns the key na...
uint64_t lastTimestamp(const QString &name) const
Last persisted signing timestamp for name, or 0 if unknown / no entry.
bool record()
Returns true on the rising-edge crossing into >= threshold.
bool refreshOutgoingTimestamp()
bool init(mavlink_channel_t channel, QByteArrayView key, mavlink_accept_unsigned_t callback, uint64_t persistedTimestamp=0, const QString &keyName={}, bool signOutgoing=true)
QString keyHint() const
bool setAcceptUnsignedCallback(mavlink_accept_unsigned_t callback)
Swap the accept-unsigned callback without resetting the key. Returns false if signing isn't enabled.
QGC::AutoSuspendGuard suspendAutoDetect()
RAII handle that suspends auto-detect for the guard's lifetime; release is automatic on destruction.
int streamCount() const
bool isEnabled() const
bool consumeStatusTransition(mavlink_channel_t channel)
True if last_status changed since previous call; sole transition-detection source.
TimestampSnapshot currentTimestampAndName() const
Returns current timestamp and active key name. Returns {0, ""} when signing is not enabled.
void signingConfirmed(const QString &keyName)
Emitted exactly once per begin*() on success. keyName is the enabled key, or empty for disable.
SigningController(mavlink_channel_t channel, QObject *parent=nullptr)
std::optional< SigningFailure > tryBeginEnable(uint8_t expectedSysId, const QString &keyName, const MAVLinkSigning::SigningKey &keyBytes)
Begin pending-enable. Caller must send SETUP_SIGNING only on nullopt; outcome arrives via signingConf...
std::optional< SigningFailure > tryBeginDisable(uint8_t expectedSysId)
Atomic check-and-commit for disable; same contract as tryBeginEnable.
QString keyName() const
bool initSigningImmediate(QByteArrayView key, MAVLinkSigning::UnsignedAcceptancePolicy policy, const QString &keyNameHint={})
Bypasses the FSM; used by tests and auto-detect. Non-empty keyNameHint seeds the persisted timestamp.
bool processFrame(bool framingOk, const mavlink_message_t &message)
Per-frame entry point; drives burst alerts, auto-detect, and the FSM. Returns true on auto-detect.
QString statusText() const
SigningFailure::Reason FailReason
void cancelPending(const QString &detail={})
void alertRaised(const QString &detail)
~SigningController() override
SigningStatus status() const
void keyAutoDetected(const QString &keyName)
void signingFailed(SigningFailure failure)
Emitted exactly once per begin*() on failure (timeout, init error, cancel, re-entry).
Reason a signing operation failed. Used by SigningController error path and Vehicle::signingFailed.
std::array< uint8_t, kSigningKeySize > SigningKey
std::array avoids QByteArray COW detach so secureZero() actually wipes the bytes.
QString signingStatusString(mavlink_channel_t channel)
bool verifySignature(QByteArrayView key, const mavlink_message_t &message)
Verify a key against a signed message's signature.
bool isMessageSigned(const mavlink_message_t &message)
Returns true if the message has a MAVLink2 signature.
mavlink_accept_unsigned_t callbackForPolicy(UnsignedAcceptancePolicy policy)
Maps a high-level policy to the underlying libmavlink callback.
void logSigningFailure(mavlink_channel_t channel)
void secureZero(void *data, size_t size)
QString statusText