Skip to content

fix(smartlink): TOFU cert fingerprint pin for WAN connections (GHSA-wfx7-w6p8-4jr2 phase 1)#2947

Merged
ten9876 merged 1 commit into
mainfrom
auto/h1-wan-cert-pin-phase1
May 22, 2026
Merged

fix(smartlink): TOFU cert fingerprint pin for WAN connections (GHSA-wfx7-w6p8-4jr2 phase 1)#2947
ten9876 merged 1 commit into
mainfrom
auto/h1-wan-cert-pin-phase1

Conversation

@ten9876

@ten9876 ten9876 commented May 22, 2026

Copy link
Copy Markdown
Collaborator

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

…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>
@ten9876 ten9876 requested a review from jensenpat as a code owner May 22, 2026 05:14
@ten9876 ten9876 enabled auto-merge (squash) May 22, 2026 05:14
@ten9876 ten9876 merged commit 5275f42 into main May 22, 2026
4 checks passed
@ten9876 ten9876 deleted the auto/h1-wan-cert-pin-phase1 branch May 22, 2026 05:33
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>
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.

1 participant