Skip to content

feat(smartlink): enforce TLS cert pin on mismatch + Pinned Certs UI#3026

Merged
ten9876 merged 3 commits into
mainfrom
auto/fix-2951
May 24, 2026
Merged

feat(smartlink): enforce TLS cert pin on mismatch + Pinned Certs UI#3026
ten9876 merged 3 commits into
mainfrom
auto/fix-2951

Conversation

@ten9876

@ten9876 ten9876 commented May 24, 2026

Copy link
Copy Markdown
Collaborator

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 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}):

  • 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}):

  • Forwards certFingerprintMismatch from active WAN
  • accept / rejectPresentedWanCert() convenience methods

Operator dialog (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):

  • 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:

  • 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

  • Commits are signed (squash-merge handles)
  • No new flat-key AppSettings calls — same key, nested JSON
  • All meter UI uses MeterSmoother (N/A)
  • Documentation updated if user-visible behavior changed (release notes will reference GHSA)
  • Security-sensitive changes reference a GHSA (GHSA-wfx7-w6p8-4jr2)

Closes #2951.

🤖 Generated with Claude Code

ten9876 and others added 2 commits May 23, 2026 18:45
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>
@ten9876 ten9876 requested a review from jensenpat as a code owner May 24, 2026 01:48
@ten9876 ten9876 enabled auto-merge (squash) May 24, 2026 01:48

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Versionemit versionReceived(...)
  • MessageType::Handlem_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)

  1. 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() after acceptPresentedWanCert() (or on a RadioModel signal) would close the loop. Pure UX, not a correctness issue.

  2. forgetAllPinnedCerts() writes QByteArray() rather than removing the key. Functionally fine — loadCertCache() returns {} for the empty-byte-array case — but AppSettings::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>
@ten9876

ten9876 commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch @aethersdr-agent — the gap is real. processLine was going to fire connected() on attacker-fabricated H bytes during the operator's decision window, exactly as you described, even though the radio itself never sees wan validate.

Pushed ae94635 addressing all three points:

  1. The gap: early-out in processLine while m_awaitingCertDecision is true. Suppression is now symmetric — nothing sent to the radio AND nothing acted on from the radio until the operator decides. Comment in WanConnection.cpp documents the threat-model intent so future readers don't accidentally unwind it.
  2. Stale Pinned Certs table after Accept: MainWindow's accept branch now calls m_radioSetupDialog->refreshPinnedCertsTable() if the dialog is open. Made refreshPinnedCertsTable public for this.
  3. forgetAllPinnedCerts cleanup: uses AppSettings::remove() instead of writing an empty QByteArray.

Thanks for the thorough read — the symmetric-suppression framing is the right way to think about it.

73, Jeremy KK7GWY & Claude (AI dev partner)

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Confirmed ae94635 — all three land cleanly. Took a careful pass over the diff:

1. Symmetric suppression in processLine — the early-out is positioned correctly at the very top of the function, before any state mutation. That matters because the next bytes in the wire buffer after a paused TLS handshake would normally be V<version>\n and H<handle>\n, and MessageType::Handle is the case that flips m_connected = true and emits connected(). With the suppression in place, even if an attacker races bytes into the socket during the modal, they hit qCDebug(... "suppressed during cert-pin decision") and never reach the switch. The comment captures the threat-model reasoning so a future cleanup pass doesn't fold the check into a "minor optimization." Good.

One small extra property worth noting: disconnectFromRadio() clears both m_awaitingCertDecision and m_presentedFingerprintHex, so the reject path can't leave the suppression latched on a stale flag if reject races with a transport-level error. Already correct in your disconnectFromRadio() patch — just flagging it as a desirable invariant.

2. refreshPinnedCertsTable() after Accept — flow is synchronous (acceptPresentedWanCert()WanConnection::acceptPresentedCert()storeFingerprint() writes AppSettings → returns → MainWindow calls refreshPinnedCertsTable()), so by the time the table reloads, listPinnedCerts() will see the new pinnedAt timestamp. Exactly what's needed. The if (m_radioSetupDialog) guard handles the common case where the dialog isn't open without any churn.

3. forgetAllPinnedCerts using AppSettings::remove() — proper cleanup, no orphan empty QByteArray lingering in QSettings. Matches Principle V intent (the key truly disappears on full clear).

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 (pre-phase 2) in the table and then triggering a mismatch-and-accept correctly rewrites it into the Phase 2 {fp, pinnedAt} shape (rather than, say, double-nesting). The code path is straightforward (storeFingerprint always emits Phase 2 shape via obj[host] = entry, which overwrites whatever was there), but worth a manual confirmation once you have a SmartLink session to test against.

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

@ten9876 ten9876 merged commit a874e45 into main May 24, 2026
5 checks passed
@ten9876 ten9876 deleted the auto/fix-2951 branch May 24, 2026 03:07
ten9876 added a commit that referenced this pull request May 24, 2026
## 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>
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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

H1 Phase 2: WAN cert mismatch dialog + Pinned Certificates settings UI

1 participant