Skip to content

fix: send-counter wrap guard (nonce reuse) + warning sweep, tidy-gated - #25

Merged
emir-hasanbegovic merged 9 commits into
mainfrom
fix/warning-sweep
Jul 21, 2026
Merged

fix: send-counter wrap guard (nonce reuse) + warning sweep, tidy-gated#25
emir-hasanbegovic merged 9 commits into
mainfrom
fix/warning-sweep

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Two strands on one branch (single-branch fold by maintainer direction):

  1. Send-counter wrap guard (serious, crypto) — the UDP send counter was a uint32 fetch_add with no exhaustion guard: at 2^32 packets in one unbroken session it wrapped and kept sealing ChaCha20-Poly1305 ciphertexts under reused (key, nonce) pairs — an on-wire keystream-reuse confidentiality leak (~50 days at a sustained 1 kHz). The counterNeedsRepush reducer existed with zero call sites. Now wired, mirroring dish-mac's G4 design: proactive re-key past 0xF0000000 + hard go-silent backstop at exhaustion.
  2. Warning sweep — clears the 26 clang-tidy findings across src/, leaves the tree warning-clean under the CI's g++ -Werror build, and now gates the tidy step so findings cannot silently regress.

What changed

  • 8942d1e fix(linux): clear the clang-tidy findings across src/ — resolves the 26 findings the CI clang-tidy step reports on main (performance-move-const-arg, performance-enum-size, performance-no-automatic-move, modernize-return-braced-init-list, cert-*, etc.).
  • 8c253bf test(linux): mark vendored Catch2 headers SYSTEM — the vendored test framework stops contributing third-party diagnostics to the tidy/warning surface.
  • bd144df chore(ci): cap the linux-ci job with timeout-minutes — a hung job self-cancels instead of squatting the 6-hour default.
  • b12b127 fix(linux): drop useless std::move on QHash insert instead of operator[] — see the corrected note below.
  • 2f79d9f style: trim warning-sweep comments to terse why-only.
  • a6dbac6 fix(linux): never wrap the UDP send counter into ChaCha20 nonce reusesendEncrypted draws from a 64-bit counter and goes silent past 2^32−1 instead of wrapping; the 1 Hz alive tick fires a single-shot SessionHooks::rekey once the counter crosses 0xF0000000; the manager's runRekey re-PUTs and installs the fresh token/salt/key on the same socket (counters restart at 1, no state blip, stale responses can't re-arm a replaced client). Tests drive a real loopback socket across the exhaustion boundary (no wrapped packet on the wire; no counter value ever repeats under one key) and drive the tick wiring through a test seam (fires once per approach; re-arms only after the re-key lands).
  • 8082360 test(linux): pin the intentionally strict TXT port parse — corrects this PR's earlier "no behavioral change" claim: from_chars rejects leading whitespace (udp= 9443) and an explicit + (udp=+9443) that atoi accepted. Kept strict deliberately — the live responder emits bare std::to_string digits (satellite mdns_responder.cpp), so nothing real is lost. The exact accept/reject set is pinned by tests (bare digits and numeric prefixes parse; whitespace/+/empty fall back to defaults).
  • 10c9ed9 chore(ci): fail the clang-tidy gate on any reintroduced finding--warnings-as-errors='*' at the CI + ci_local.sh invocations. .clang-tidy itself stays untouched: it is the fleet-canonical config shared with satellite/dish-android, so the gate lives in the invocation, scoped to the curated (now clean) check set. Verified by probe: a reintroduced modernize-use-nullptr finding fails the step.
  • cc82d58 fix(linux): draw session material atomically for the live re-key — the proactive re-key makes setConnectionParams run against a live client for the first time; materialMtx_ now makes the (key, token, counter, replay-mark) swap atomic vs the SDL/heartbeat senders and the receive loop (dish-mac's nextSendMaterial design). Pinned by a two-thread × 40-generation hammer test: every wire packet must decrypt under the key its token selects, no (token, counter) pair repeats (failed 2/10 runs against an unlocked draw; 0/10 with the lock).

Note on the QHash inserts (corrected)

An earlier revision of this note claimed "QHash::insert has no rvalue overload, the move was always a no-op". That claim is wrong on Qt 6QHash::insert(const Key&, T&&) exists (qhash.h), so the moves were real. The code change stands for the right reasons anyway: operator[] took QHash through its detach→new Span[] path, which g++ -O2 flags with -Walloc-size-larger-than= inside qhash.h (a Qt/GCC false positive; clang never emits it), and dropping a move of a copyable std::function typedef in a UI-rate rebuild is harmless. insert stays; the tidy finding stays cleared; zero g++ build warnings.

Test evidence

Authoritative ubuntu-24.04 container gate on the branch head (cc82d58), mirroring linux-ci under g++:

  • clang-format 22.1.4 (pinned): clean
  • Debug build (tests on): 0 compiler warnings under -Werror
  • 235/235 tests pass (up from 228: 7 new — exhaustion silence, wire monotonicity, re-key restart, re-key material hammer, tick wiring ×2, strict TXT parse)
  • clang-tidy under the new --warnings-as-errors='*' gate: clean, and the probe (deliberately reintroduced finding) fails the step as intended
  • Release build: green

Local macOS scripts/ci_local.sh (clang) also green end-to-end; new-test flake check: 10 consecutive clean runs of the hammer, 5 of the wiring tests.

🤖 Generated with Claude Code

CI ran clang-tidy but did not gate on it (.clang-tidy has
WarningsAsErrors: ''), so 26 diagnostics rode along in every build log.
Root-cause each rather than suppress:

- performance-enum-size (12 enums): give each scoped decision/state enum an
  explicit std::uint8_t base. Wire values live in Models/Protocol.h as
  separate std::uint8_t/uint16_t constants, so this only shrinks the enums
  and never touches the wire encoding.
- performance-move-const-arg (AppModel::rebuild): QHash::insert takes
  const T&, so std::move was a no-op; move the sender in via operator[].
- performance-no-automatic-move (ConnectionStore): drop const on the
  returned QString locals so they move out instead of copying.
- performance-unnecessary-value-param: take the shared_ptr in
  markConnected by const ref; move the response callback into the inner
  lambda capture in makeHooks.
- bugprone-unchecked-optional-access (runReconcile): read connectionId()
  once into a local, check it, then deref the local. The mutating
  setReconcileInFlight() call between the old check and deref is what
  defeated the flow analysis.
- modernize-return-braced-init-list (SessionCrypto): return {hex}.
- cert-err34-c (MdnsDiscovery): parse TXT integers with std::from_chars
  instead of std::atoi (checked, no silent-failure path).
- cert-err33-c (main): explicitly discard the best-effort stderr fprintf
  on the fatal libsodium-init path.
- cert-dcl37-c/cert-dcl51-cpp (SDLGamepadBridge): the SDL-dictated
  _SDL_GameController forward declaration's NOLINT named only
  bugprone-reserved-identifier, but cert-* is enabled so clang-tidy
  reports under the cert alias; name all three aliases so the existing
  documented suppression actually applies.
Catch2's expression-decomposer templates (ITransientExpression,
BinaryExpr) carry virtual functions with a non-virtual destructor by
design, tripping -Wnon-virtual-dtor from dish_warnings at every
TEST_CASE (88 warnings per CI build). Mark the FetchContent Catch2
targets' include dirs SYSTEM -- the same treatment satellite gives its
vendored libraries -- so the noise clears without dropping
-Wnon-virtual-dtor from our own warning set. A system-installed Catch2
(the find_package branch) is already imported as SYSTEM by CMake.
Add timeout-minutes: 30 (matching satellite's linux-ci lane) so a hung
build/test/tidy step is reclaimed instead of burning the six-hour
default on the billed private-repo runner.
The clang-tidy sweep had silenced performance-move-const-arg at the four
rebuild() inserts by switching QHash::insert(k, std::move(v)) to
operator[] = std::move(v). But operator[] takes QHash through its
detach->rebuild path, which g++ -O2 flags with -Walloc-size-larger-than
inside qhash.h (2 new build warnings, absent on main). QHash::insert has
no rvalue overload, so the original move was always a no-op copy anyway;
dropping the move clears the tidy finding with zero build warnings and
matches main's actual runtime behavior.
The send counter was a uint32 fetch_add with no exhaustion guard: at 2^32
packets in one unbroken session it wrapped and kept sealing ciphertexts
under reused (key, nonce) pairs — an on-wire keystream-reuse leak of input
reports (contract §Crypto forbids exactly this; ~50 days at 1 kHz). The
counterNeedsRepush reducer existed but had zero call sites.

Mirror dish-mac's G4 design:
- sendEncrypted draws from a 64-bit AtomicCounter and goes SILENT past
  2^32-1 instead of wrapping; sendCounter() clamps so the poll keeps
  reading re-PUT needed.
- The 1 Hz alive tick fires a new SessionHooks::rekey (single-shot per
  approach) once the counter crosses 0xF0000000; the manager's runRekey
  re-PUTs the session and installs the fresh token/salt/key on the SAME
  socket — counters restart at 1, the hot path never blips, and a stale
  response cannot re-arm a replaced client.

Tests drive a real loopback socket across the exhaustion boundary (no
wrapped packet on the wire, no counter value ever repeats under one key)
and drive the tick wiring through a test seam (fires once, re-arms only
after the re-key lands).

Lands on the warning-sweep branch by maintainer direction (single-branch
preference) rather than a separate fix/counter-wrap-guard branch.
The atoi→from_chars swap in the warning sweep was not strictly
behavior-preserving: from_chars rejects leading whitespace and an
explicit '+' that atoi accepted. Keep the strict parse — the live
responder emits bare std::to_string digits (satellite mdns_responder.cpp)
so nothing real is lost — and pin the exact accept/reject set: bare
digits and a numeric prefix parse, whitespace/'+'/empty fall back to the
default port. The PR body's no-behavioral-change claim is corrected
alongside.
The sweep cleared all 26 findings but left WarningsAsErrors '' so they
could silently regress. Pass --warnings-as-errors='*' at the CI and
ci_local invocations instead of editing .clang-tidy — the config file is
fleet-canonical (shared with satellite/dish-android) and stays untouched;
the flag gates exactly the curated, now-clean check set.
The proactive re-key makes setConnectionParams run against a LIVE client
for the first time — the SDL/heartbeat senders and the receive loop read
key_/token_/counters concurrently, so an unguarded swap could pair an old
key with a fresh counter (a mis-sequenced nonce the server replay guard
then chokes on), tear the 32-byte key mid-copy, or stamp a stale replay
mark onto the fresh session. materialMtx_ now guards the material: one
hold draws (key, token, counter) together on the send path (dish-mac's
nextSendMaterial design), the receive loop snapshots the same way and
only advances the replay mark if no re-key raced its decrypt.

Pinned by a hammer test: two sender threads across 40 re-key generations;
every wire packet must decrypt under the key its token selects and no
(token, counter) pair may repeat. Probabilistic pre-fix (a race), it
failed 2/10 runs against an unlocked draw on a 4-core host; 0/10 with the
lock.
@emir-hasanbegovic emir-hasanbegovic changed the title chore: warning sweep — clear clang-tidy findings, g++-verified clean fix: send-counter wrap guard (nonce reuse) + warning sweep, tidy-gated Jul 20, 2026
@emir-hasanbegovic
emir-hasanbegovic merged commit 960486e into main Jul 21, 2026
7 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the fix/warning-sweep branch July 21, 2026 16:33
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.

2 participants