fix(smartlink): TOFU cert fingerprint pin for WAN connections (GHSA-wfx7-w6p8-4jr2 phase 1)#2947
Merged
Merged
Conversation
…fx7-w6p8-4jr2 phase 1) WAN (SmartLink) TLS still calls QSslSocket::VerifyNone — the radio's self-signed cert leaves no chain to validate against — but that means any MITM on the path between AetherSDR and the radio can intercept all commands and audio. Realistic exposures: hotel/coffee-shop WiFi with a TLS proxy, malicious ISP, BGP hijack. An attacker who proxies the session can also issue CAT commands under the operator's authenticated session (TX on, freq/mode/power change) — FCC-relevant for a licensed station. Phase 1 introduces TOFU (Trust On First Use) fingerprint capture: - On every WAN connect, compute SHA-256 of the presented cert. - Persist per-host fingerprints as a JSON object in AppSettings under the key "SmartLinkCertFingerprintCache" (one nested value, not a flat namespace per-host — matches the AppSettings refactor direction). - First connect to a host: qCInfo + persist. - Subsequent matching connect: qCDebug (silent in non-debug builds). - Mismatch: qCWarning naming both fingerprints and the advisory ID. Phase 1 is **warn-only** — no UI dialog, no enforcement, no disconnect. This deploys the capture path so we have real-world data (legitimate firmware-update rotations, radio replacements) before phase 2 adds the enforcement + "Forget cert" Settings UI. The TOFU check lives in onTlsConnected (after handshake completes, when peerCertificate() is populated), not in onSslErrors — onSslErrors stays unchanged as the "ignore self-signed" path. Local build verified (cmake --build … --target AetherSDR exits 0). Not functionally tested via a live SmartLink connect — the operator cannot trigger SmartLink while on-network with the radio. Change is additive: existing LAN connection path is unaffected, new WAN code runs after a successful TLS handshake so it cannot block connection establishment, and the worst-case observable effect is one extra qCInfo log line on first WAN connect. Phase 2 (next release) adds: mismatch dialog, "Forget radio certificate" Settings button, and optional disconnect-on-reject. Principle I. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 22, 2026
ten9876
added a commit
that referenced
this pull request
May 24, 2026
…3026) ## Summary Closes #2951. Phase 2 of [GHSA-wfx7-w6p8-4jr2](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 `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 #2951. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G6PWY-Chris
pushed a commit
to G6PWY-Chris/AetherSDR
that referenced
this pull request
Jun 22, 2026
…fx7-w6p8-4jr2 phase 1) (aethersdr#2947) WAN (SmartLink) TLS still calls QSslSocket::VerifyNone — the radio's self-signed cert leaves no chain to validate against — but that means any MITM on the path between AetherSDR and the radio can intercept all commands and audio. Realistic exposures: hotel/coffee-shop WiFi with a TLS proxy, malicious ISP, BGP hijack. An attacker who proxies the session can also issue CAT commands under the operator's authenticated session (TX on, freq/mode/power change) — FCC-relevant for a licensed station. Phase 1 introduces TOFU (Trust On First Use) fingerprint capture: - On every WAN connect, compute SHA-256 of the presented cert. - Persist per-host fingerprints as a JSON object in AppSettings under the key "SmartLinkCertFingerprintCache" (one nested value, not a flat namespace per-host — matches the AppSettings refactor direction). - First connect to a host: qCInfo + persist. - Subsequent matching connect: qCDebug (silent in non-debug builds). - Mismatch: qCWarning naming both fingerprints and the advisory ID. Phase 1 is **warn-only** — no UI dialog, no enforcement, no disconnect. This deploys the capture path so we have real-world data (legitimate firmware-update rotations, radio replacements) before phase 2 adds the enforcement + "Forget cert" Settings UI. The TOFU check lives in onTlsConnected (after handshake completes, when peerCertificate() is populated), not in onSslErrors — onSslErrors stays unchanged as the "ignore self-signed" path. Local build verified (cmake --build … --target AetherSDR exits 0). Not functionally tested via a live SmartLink connect — the operator cannot trigger SmartLink while on-network with the radio. Change is additive: existing LAN connection path is unaffected, new WAN code runs after a successful TLS handshake so it cannot block connection establishment, and the worst-case observable effect is one extra qCInfo log line on first WAN connect. Phase 2 (next release) adds: mismatch dialog, "Forget radio certificate" Settings button, and optional disconnect-on-reject. Principle I. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G6PWY-Chris
pushed a commit
to G6PWY-Chris/AetherSDR
that referenced
this pull request
Jun 22, 2026
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WAN (SmartLink) TLS still calls QSslSocket::VerifyNone — the radio's
self-signed cert leaves no chain to validate against — but that means
any MITM on the path between AetherSDR and the radio can intercept all
commands and audio. Realistic exposures: hotel/coffee-shop WiFi with
a TLS proxy, malicious ISP, BGP hijack. An attacker who proxies the
session can also issue CAT commands under the operator's authenticated
session (TX on, freq/mode/power change) — FCC-relevant for a licensed
station.
Phase 1 introduces TOFU (Trust On First Use) fingerprint capture:
the key "SmartLinkCertFingerprintCache" (one nested value, not a
flat namespace per-host — matches the AppSettings refactor direction).
Phase 1 is warn-only — no UI dialog, no enforcement, no disconnect.
This deploys the capture path so we have real-world data (legitimate
firmware-update rotations, radio replacements) before phase 2 adds the
enforcement + "Forget cert" Settings UI.
The TOFU check lives in onTlsConnected (after handshake completes, when
peerCertificate() is populated), not in onSslErrors — onSslErrors stays
unchanged as the "ignore self-signed" path.
Local build verified (cmake --build … --target AetherSDR exits 0).
Not functionally tested via a live SmartLink connect — the operator
cannot trigger SmartLink while on-network with the radio. Change is
additive: existing LAN connection path is unaffected, new WAN code
runs after a successful TLS handshake so it cannot block connection
establishment, and the worst-case observable effect is one extra
qCInfo log line on first WAN connect.
Phase 2 (next release) adds: mismatch dialog, "Forget radio certificate"
Settings button, and optional disconnect-on-reject.
Principle I.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com