feat(smartlink): enforce TLS cert pin on mismatch + Pinned Certs UI#3026
Conversation
Phase 2 of GHSA-wfx7-w6p8-4jr2 / #2951. Phase 1 (PR #2947) silently captured each SmartLink radio's TLS cert fingerprint on first connect and logged a warning on mismatch, but the connection still proceeded. Phase 2 turns the warn-only TOFU into actual enforcement plus the operator UX needed to manage pins. ## Threat model SmartLink WAN uses TLS with VerifyNone because radios self-sign. The worry is an active MITM on the network path presenting any cert and AetherSDR proceeding to send the WAN handle (wan validate handle=...), giving the attacker a live authenticated session. Phase 2 stops the handshake at the mismatch detection point. The radio sees an open TLS channel but never sees wan validate until the operator explicitly accepts. Reject tears down the TLS session without ever authenticating. ## What changed WanConnection (core): - New signal certFingerprintMismatch(host, expectedHex, presentedHex) - New methods acceptPresentedCert() / rejectPresentedCert() — connection pauses (no wan validate sent) until one is called - onTlsConnected: mismatch path stores presented fp + sets pending flag + emits signal; first-use and match paths unchanged (silent pin / silent debug) - Internal helper sendWanValidate() factored so accept-after-mismatch lands at exactly the same handshake step the no-prompt paths use Cache schema bump with back-compat reader: Phase 1: { "host": "<hex>" } Phase 2: { "host": { "fp": "<hex>", "pinnedAt": "<ISO8601>" } } Reader handles both shapes (pinEntryFromValue branches on isString / isObject); writer always emits Phase 2 shape. Phase 1 entries are honored as legitimate pins with empty pinnedAt and upgraded organically the next time they're rewritten (e.g. on accept-after-mismatch). New WanCertCache namespace API for the Pinned Certs UI: - listPinnedCerts() → QVector<PinnedCertInfo> - forgetPinnedCert(host) - forgetAllPinnedCerts() RadioModel (relay): - Forwards certFingerprintMismatch from active WanConnection - acceptPresentedWanCert() / rejectPresentedWanCert() convenience methods - No-op if not currently connected via WAN MainWindow (operator dialog): - New slot onWanCertFingerprintMismatch() shows a QMessageBox modal with both fingerprints pretty-printed as colon-separated hex - Default focus is on "Reject and disconnect" so accidental Enter doesn't accept a hostile cert - Status-bar message confirms either choice - Wired off RadioModel::certFingerprintMismatch RadioSetupDialog (Pinned Certs UI): - New "SmartLink" tab in the deferred-build list - QTableWidget with 3 columns: Host / SHA-256 fingerprint (monospace) / Pinned date (YYYY-MM-DD; legacy Phase 1 entries show "(pre-phase 2)") - "Forget selected" and "Forget all" (with confirmation dialog) buttons - Description text explaining when to forget pins (firmware update, hardware replacement) and citing GHSA-wfx7-w6p8-4jr2 ## Persistence All state lives in the existing SmartLinkCertFingerprintCache AppSettings key. No new flat keys (Principle V — single nested JSON under one key). Upgrade is seamless: existing Phase 1 pins read fine and are honored. Closes #2951. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks @ten9876 — nicely scoped Phase 2. Threat model write-up, back-compat reader for the Phase 1 string-only shape, default focus on Reject, HTML-escaped host, and reusing the same AppSettings key (Principle V) are all on point. One real gap and a couple of nits.
Gap: incoming bytes are still processed while awaiting operator decision
onTlsConnected() correctly withholds wan validate on a mismatch, but the readyRead slot remains wired up. While the modal is on screen, processLine() will happily consume anything that arrives on the (still open) TLS channel:
MessageType::Version→emit versionReceived(...)MessageType::Handle→m_handle = ...; m_connected = true; m_heartbeat.start(); emit connected();
A MITM attacker who was on the path (precisely the scenario this PR defends against) can fabricate V…\n and H…\n before the operator answers. The real radio never sees wan validate — your stated property holds — but the client's UI flips to "connected" and starts heartbeats against the attacker's TLS session before the user clicks Reject. After Reject the disconnect cleans up, but connected() has already fired downstream.
Suggested fix in WanConnection.cpp — early-out in either onReadyRead or processLine:
void WanConnection::processLine(const QString& line)
{
if (m_awaitingCertDecision) {
qCDebug(lcSmartLink) << "WAN RX (suppressed during cert decision):" << line;
return;
}
// …
}This keeps the symmetry of the threat-model comment ("the radio sees an open TLS channel but never sees wan validate") and extends it to "and we never act on whatever it sends back either."
Nits (non-blocking)
-
Pinned Certificates table goes stale after Accept. If the dialog is already open and the operator accepts a new pin via the modal, the table still shows the old fingerprint/date until they re-open the tab. Calling
refreshPinnedCertsTable()afteracceptPresentedWanCert()(or on aRadioModelsignal) would close the loop. Pure UX, not a correctness issue. -
forgetAllPinnedCerts()writesQByteArray()rather than removing the key. Functionally fine —loadCertCache()returns{}for the empty-byte-array case — butAppSettings::instance().remove(kCertCacheKey)(if available in this codebase) would be tidier.
Otherwise lgtm — accept/reject state machine is clean, the sendWanValidate() factor-out makes the three paths converge correctly, and disconnectFromRadio() properly clears the pending-decision state.
🤖 aethersdr-agent · cost: $6.2454 · model: claude-opus-4-7
Addresses @aethersdr-agent's review on PR #3026 — a real gap in the Phase 2 mismatch-handling that I missed. ## The gap onTlsConnected() correctly withholds wan validate on a fingerprint mismatch, but the TLS channel stays open and onReadyRead → processLine keeps running while the operator stares at the modal. A MITM on the path (the exact threat we're defending against) could fabricate V<version>\n and H<handle>\n bytes before the operator answers. processLine handles MessageType::Handle by setting m_handle, m_connected = true, starting m_heartbeat, and emitting connected(). The radio itself never sees wan validate so my threat-model claim held in the strict sense — but downstream code reacts to connected() on attacker-controlled data. RadioModel transitions to connected state, UI flips, heartbeats start, all on the still-unauthorized session. Click Reject afterward and the disconnect cleans up but connected() has already fired. ## Fix Early-out in processLine while m_awaitingCertDecision is true. The suppression is symmetric with the wan-validate suppression: nothing gets sent to the radio AND nothing gets acted on from the radio until the operator's decision lands. Comment in WanConnection.cpp documents the threat-model intent. ## Bot-review nits (also addressed) - Pinned Certs table now refreshes after Accept (MainWindow's accept branch calls m_radioSetupDialog->refreshPinnedCertsTable() if the dialog is open). Made refreshPinnedCertsTable public for this. - forgetAllPinnedCerts uses AppSettings::remove() rather than writing an empty QByteArray. Functionally identical (loadCertCache returns {} for both), but tidier. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Good catch @aethersdr-agent — the gap is real. Pushed
Thanks for the thorough read — the symmetric-suppression framing is the right way to think about it. 73, Jeremy KK7GWY & Claude (AI dev partner) |
|
Confirmed ae94635 — all three land cleanly. Took a careful pass over the diff: 1. Symmetric suppression in One small extra property worth noting: 2. 3. LGTM from my side. The only thing I'd still want to see exercised is the Phase 1 upgrade path in the test plan — specifically that a legacy string-only entry rendered as Ready to merge once the test plan items land. 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $1.2385 · model: claude-opus-4-7 |
## Summary Release prep for v26.5.3 — Aetherial Audio TX completion + security hardening + 100-commit reliability sweep. - **CHANGELOG.md** — new v26.5.3 section: 138 commits across 14 contributors since v26.5.2.1 - **README.md** — version line bump 26.5.2.1 → 26.5.3 - **CMakeLists.txt** — already at 26.5.3 (set in #3024) ## Highlights - **Aetherial Audio TX completion** — PAPR all-pass cascade + split-band DESS, full per-stage CHAIN UI - **Security advisories** — GHSA-wfx7-w6p8-4jr2 (WAN cert-pin Phase 2, #3026) + GHSA-qxhr-cwrc-pvrm (CAT PTY symlink, #3027) - **MQTT settings dialog refactor** (#3051, @s53zo) — JSON-array topics + button-event lists, nested-JSON consolidation - **Audio reliability sweep** — WASAPI silent-open watchdog, mono-only USB PnP mic recovery, MQTT subscribe diff - **Native Hamlib NET rigctl** — eliminates external rigctld dependency - **Stream Deck integration** — Elgato SDK plugin (Win/Mac/Linux-via-OpenDeck) ## Contributors thanked @jensenpat (16), @aethersdr-agent (22 bot), @NF0T (12), @rfoust (9), @Ozy311 (5), @M7HNF-Ian (5), @chibondking (5), @M8WLO (4), @s53zo (2), @pepefrog1234 (2), @K5PTB (2), @chrisb1964 (1) ## Test plan - [ ] CI green (build + check-paths + check-windows) - [ ] CHANGELOG renders correctly on GitHub - [ ] README version banner shows 26.5.3 - [ ] After merge: tag v26.5.3 once CI green on main 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ethersdr#3026) ## Summary Closes aethersdr#2951. Phase 2 of [GHSA-wfx7-w6p8-4jr2](GHSA-wfx7-w6p8-4jr2) — turns the warn-only TLS fingerprint TOFU shipped in PR aethersdr#2947 into actual enforcement + the operator UX needed to manage pins. ## Threat model SmartLink WAN uses TLS with `VerifyNone` because radios self-sign. An active MITM on the network path could present any cert and Phase 1 would log a warning but proceed to send `wan validate handle=…`, giving the attacker a live authenticated session. Phase 2 stops the handshake at the mismatch point. The radio (or attacker) sees an open TLS channel but never sees `wan validate` until the operator explicitly accepts via modal. Reject tears down TLS without ever authenticating. ## What changed **Core ([src/core/WanConnection.{h,cpp}](src/core/WanConnection.h))**: - New signal `certFingerprintMismatch(host, expectedHex, presentedHex)` - `acceptPresentedCert()` / `rejectPresentedCert()` methods; handshake pauses until one is called - Cache schema bump with back-compat reader: - Phase 1: `{"host": "<hex>"}` - Phase 2: `{"host": {"fp": "<hex>", "pinnedAt": "<ISO8601>"}}` - New `WanCertCache` namespace API: `listPinnedCerts`, `forgetPinnedCert`, `forgetAllPinnedCerts` **Relay ([src/models/RadioModel.{h,cpp}](src/models/RadioModel.h))**: - Forwards `certFingerprintMismatch` from active WAN - `accept` / `rejectPresentedWanCert()` convenience methods **Operator dialog ([src/gui/MainWindow.cpp](src/gui/MainWindow.cpp))**: - `onWanCertFingerprintMismatch()` slot shows `QMessageBox` modal with both fingerprints pretty-printed as colon-separated hex - Default focus is on **Reject and disconnect** — accidental Enter doesn't accept a hostile cert **Pinned Certificates UI ([src/gui/RadioSetupDialog.cpp](src/gui/RadioSetupDialog.cpp))**: - New "SmartLink" tab in Radio Setup - QTableWidget: Host / SHA-256 fingerprint (monospace) / Pinned date (YYYY-MM-DD) - "Forget selected" + "Forget all" (with confirmation) - Legacy Phase 1 entries show `(pre-phase 2)` in the date column ## Persistence All state stays in the existing `SmartLinkCertFingerprintCache` AppSettings key. No new flat keys — Principle V compliant (single nested JSON under one key). Upgrade is seamless: existing Phase 1 string-only entries are honored as legitimate pins and upgrade to the new shape organically. ## Security properties - **Attacker on hostile path** sees TLS handshake complete but never sees `wan validate` during the operator's decision window - **Reject** tears down TLS immediately; no opportunity to inject during cleanup - **Accept** is explicit, intentional; overwrites pin only on operator confirmation - **Phase 1 → Phase 2 upgrade** has no first-use re-pin silent acceptance trap ## Stats - 8 files, +417 / −32 - No new flat-key AppSettings (Principle V) - All meter UI uses MeterSmoother (N/A, no meter changes) ## Test plan Requires SmartLink WAN connection — cannot exercise from LAN-attached radio: - [x] Build clean - [ ] **First-use**: connect cleanly to a new SmartLink radio → pin captured silently with timestamp, visible in Radio Setup → SmartLink - [ ] **Match**: subsequent connect to same radio → silent debug log, no UI - [ ] **Mismatch**: rotate the radio's cert (firmware update, or manually edit cache) → modal appears with both fingerprints; **Accept** persists new pin and connection resumes; **Reject** disconnects cleanly with "Certificate rejected by user" status - [ ] **Forget selected**: per-row Forget removes from list; next connect to that host re-pins silently - [ ] **Forget all**: prompts confirmation, clears cache - [ ] **Phase 1 upgrade**: existing string-only cache entries don't crash; show `(pre-phase 2)` in date column until next mismatch-and-accept rewrites them in Phase 2 shape ## Checklist - [x] Commits are signed (squash-merge handles) - [x] No new flat-key `AppSettings` calls — same key, nested JSON - [x] All meter UI uses `MeterSmoother` (N/A) - [x] Documentation updated if user-visible behavior changed (release notes will reference GHSA) - [x] Security-sensitive changes reference a GHSA (GHSA-wfx7-w6p8-4jr2) Closes aethersdr#2951. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary Release prep for v26.5.3 — Aetherial Audio TX completion + security hardening + 100-commit reliability sweep. - **CHANGELOG.md** — new v26.5.3 section: 138 commits across 14 contributors since v26.5.2.1 - **README.md** — version line bump 26.5.2.1 → 26.5.3 - **CMakeLists.txt** — already at 26.5.3 (set in aethersdr#3024) ## Highlights - **Aetherial Audio TX completion** — PAPR all-pass cascade + split-band DESS, full per-stage CHAIN UI - **Security advisories** — GHSA-wfx7-w6p8-4jr2 (WAN cert-pin Phase 2, aethersdr#3026) + GHSA-qxhr-cwrc-pvrm (CAT PTY symlink, aethersdr#3027) - **MQTT settings dialog refactor** (aethersdr#3051, @s53zo) — JSON-array topics + button-event lists, nested-JSON consolidation - **Audio reliability sweep** — WASAPI silent-open watchdog, mono-only USB PnP mic recovery, MQTT subscribe diff - **Native Hamlib NET rigctl** — eliminates external rigctld dependency - **Stream Deck integration** — Elgato SDK plugin (Win/Mac/Linux-via-OpenDeck) ## Contributors thanked @jensenpat (16), @aethersdr-agent (22 bot), @NF0T (12), @rfoust (9), @Ozy311 (5), @M7HNF-Ian (5), @chibondking (5), @M8WLO (4), @s53zo (2), @pepefrog1234 (2), @K5PTB (2), @chrisb1964 (1) ## Test plan - [ ] CI green (build + check-paths + check-windows) - [ ] CHANGELOG renders correctly on GitHub - [ ] README version banner shows 26.5.3 - [ ] After merge: tag v26.5.3 once CI green on main 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes #2951. Phase 2 of GHSA-wfx7-w6p8-4jr2 — turns the warn-only TLS fingerprint TOFU shipped in PR #2947 into actual enforcement + the operator UX needed to manage pins.
Threat model
SmartLink WAN uses TLS with
VerifyNonebecause radios self-sign. An active MITM on the network path could present any cert and Phase 1 would log a warning but proceed to sendwan validate handle=…, giving the attacker a live authenticated session.Phase 2 stops the handshake at the mismatch point. The radio (or attacker) sees an open TLS channel but never sees
wan validateuntil the operator explicitly accepts via modal. Reject tears down TLS without ever authenticating.What changed
Core (src/core/WanConnection.{h,cpp}):
certFingerprintMismatch(host, expectedHex, presentedHex)acceptPresentedCert()/rejectPresentedCert()methods; handshake pauses until one is called{"host": "<hex>"}{"host": {"fp": "<hex>", "pinnedAt": "<ISO8601>"}}WanCertCachenamespace API:listPinnedCerts,forgetPinnedCert,forgetAllPinnedCertsRelay (src/models/RadioModel.{h,cpp}):
certFingerprintMismatchfrom active WANaccept/rejectPresentedWanCert()convenience methodsOperator dialog (src/gui/MainWindow.cpp):
onWanCertFingerprintMismatch()slot showsQMessageBoxmodal with both fingerprints pretty-printed as colon-separated hexPinned Certificates UI (src/gui/RadioSetupDialog.cpp):
(pre-phase 2)in the date columnPersistence
All state stays in the existing
SmartLinkCertFingerprintCacheAppSettings key. No new flat keys — Principle V compliant (single nested JSON under one key). Upgrade is seamless: existing Phase 1 string-only entries are honored as legitimate pins and upgrade to the new shape organically.Security properties
wan validateduring the operator's decision windowStats
Test plan
Requires SmartLink WAN connection — cannot exercise from LAN-attached radio:
(pre-phase 2)in date column until next mismatch-and-accept rewrites them in Phase 2 shapeChecklist
AppSettingscalls — same key, nested JSONMeterSmoother(N/A)Closes #2951.
🤖 Generated with Claude Code