Skip to content

fix(nonce): sliding-window replay protection; stop NACKing ordinary packet loss - #136

Open
davelee98 wants to merge 8 commits into
OpenDisplay:mainfrom
davelee98:fix/nonce-replay-window
Open

fix(nonce): sliding-window replay protection; stop NACKing ordinary packet loss#136
davelee98 wants to merge 8 commits into
OpenDisplay:mainfrom
davelee98:fix/nonce-replay-window

Conversation

@davelee98

@davelee98 davelee98 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Nonce/replay rework for the encrypted BLE command path. 8 commits, independent of the freeze-hardening work in #135.

The field-visible fix

A pipe DATA frame (0x0081) rejected for a nonce reason is now answered with silence instead of a fatal 0x81 NACK.

Nonce rejection on that path is ordinary packet loss — it is the direct consequence of frames having been dropped — and pipe-write-protocol.md §5.2 already reserves NACKs for unrecoverable conditions, with §5.1 making a 0x81 NACK unconditionally fatal. Answering one violated the spec as written and made py-opendisplay raise IntegrityCheckError from a path its pipe send loop does not catch, killing the entire upload on the first rejected frame. Silence is a first-class signal here: the seq is absent from the next SACK mask, the client retransmits under a fresh higher counter, and that counter is accepted unconditionally.

Deliberately narrow — tag failures keep the NACK (tamper evidence, not loss), and legacy 0x0071 is left alone because its ACK discipline differs and hasn't been analysed.

The rest

  • 512-byte replay value ring → 32-byte sliding bitmap. Same guarantee, ~16× less RAM, and O(1) instead of a linear scan.
  • Check split from commit. A frame that fails later validation no longer burns its slot in the window.
  • Forward cap removed; counters are ordered numerically. The cap rejected legitimate jumps after loss.
  • Nonce failures no longer touch integrity_failures. Only a CCM tag failure is a tamper oracle; counting packet loss there was the D1 defect.

Verification

  • New host test tools/test_nonce_window.cpp, built -Werror under ASan+UBSan. UBSan is load-bearing, not decoration: it catches the x << 64 shift UB automatically rather than relying on the test author to predict it.
  • 47,445 assertions pass locally; nrf52840custom builds clean.
  • No wire-protocol change; include/opendisplay_protocol.h untouched.

Step 1 of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md.

New dependency-free src/nonce_window.h holds the whole window state machine as
static inline functions over plain values: no Arduino, no mbedtls, no millis(),
no logging, no session. That is what tools/test_nonce_window.cpp targets
(Decision D), and it keeps nonceCheck/nonceCommit file-static in encryption.cpp
(Decision C) at no cost to testability.

Representation is IPsec/DTLS shifting style (RFC 4303 / RFC 6347), not the
circular RFC 6479 / WireGuard form (Decision B): bit i == "counter
(last_seen - i) consumed", bit 0 == last_seen. Eviction and falling out of
window become the same event, so the hand-maintained D >= 2W coupling between
constants in two files ceases to exist.

That representation deletes two defects outright rather than patching them:
  D3 - no reserved sentinel, so "not seen" is a clear bit and the counter_diff
       != 0 exemption that made the highest-seen frame replayable is gone.
       [H3]: that exemption also flushed the ring, unlocking the last 32
       genuine counters, not just the last one.
  D4 - a bitmap has no insertion point, so the function-static
       replay_window_index cannot recur.

OD_NONCE_BACKWARD_BITS 256 (uint64_t[4], 32 B) is kept strictly greater than
OD_NONCE_FORWARD_CAP 128 so a legal forward slide can never exceed the bitmap
width, keeping the wholesale-clear branch off the normal path.

Cap is 128, not 64 [C1]: the old derivation (PIPE_MAX_W + MAX_PTO = 35, asserted
as a hard bound) missed the client's selective-repair transmit site, which spends
no window credit; the reachable gap is ~96 at blocks_per_ack = 1. Under a bitmap
the cap is a comparison, not storage, so the width is free.

encryptionSession shrinks by exactly 480 B (verified: sizeof 0x118 on esp32-N4).
…pering

Steps 2-4 of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. verifyNonceReplay()
is deleted outright (Decision C - no compatibility wrapper), along with its
declarations in encryption.h and the duplicate in main.h.

D1 - a nonce rejection no longer touches integrity_failures. The rule in one
line: only a CCM tag failure is evidence of tampering; a nonce failure is
evidence of a lossy link. Previously a lost window put the next frame out of
range and each such frame counted toward session destruction, after which the
device answered RESP_AUTH_REQUIRED to everything until reconnect.

D2 - nonceCheck() is pure. It writes nothing to encryptionSession on any path,
so replay state is advanced only by nonceCommit(), which runs as the FIRST
statement of decryptCommand's success arm - after aes_ccm_decrypt. Placement is
load-bearing [L2]: there is an early return in that arm for a
decrypted-but-malformed payload_length, and that frame is authentic (it passed
the tag). Today's unconditional commit does record it; committing after the
early return would silently leave an authentic frame replayable.

M1 - unsigned wrapping deltas only, no signed arithmetic. The 8 counter bytes
are parsed off the wire before the tag is verified, so an unauthenticated
attacker controls both operands: (int64_t)counter for >= 2^63 is
implementation-defined pre-C++20, the subtraction can overflow, and negating
INT64_MIN is UB. Unsigned overflow is defined as modular arithmetic, making the
expression total over all 2^64 inputs. The four tests in od_nonce_check are
ordered and the order is load-bearing - fwd and back are complements mod 2^64
and cannot both be small.

M3 - resetNonceState() names all four shared fields explicitly. The two callers
share only these four and are opposite on everything else, so a helper described
loosely as "the bitmap and last_seen_counter" would invite dropping
nonce_counter = 0 - which would carry the device's outbound counter across a
re-auth while the client restarts at 0, walking into the [H2] keystream reuse
against itself.

L7 - both nonce-rejection logs demoted from ERROR to WARN and rate-limited to
one per 5 s (one shared budget, so alternating between them cannot bypass it).
The out-of-window log now fires routinely on a lossy link, and with counting
removed nothing else throttles a peer driving the session-id line, which also
no longer dumps two full session IDs. Routing NONCE_BAD_SESSION to
"integrity_failures untouched" is a deliberate policy change, not a consequence
of D1: a mismatched session id is usually a stale client talking to a device
that re-authenticated.

decryptCommand gains a NonceResult* reason out-param (internal signature only,
nothing on the wire) so its single caller can distinguish nonce rejection from
tag failure. Step 4b uses it.
Step 4b / [H1] of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. Without this,
Phase 1 saves the device but still loses the transfer.

Every decryptCommand failure - nonce and tag alike - produced the same
unencrypted 3-byte RESP_NACK, and the client turns that shape into
IntegrityCheckError before it ever reaches pipe-frame classification
(device.py:833-838). The pipe send loop's only except is BLETimeoutError, so
one out-of-window frame aborts the whole upload - and the frame the client would
have repaired is the one whose NACK killed the transfer. No forward cap is wide
enough to fix that.

Now: NONCE_OUT_OF_WINDOW / NONCE_REPLAY on CMD_PIPE_WRITE_DATA sends NOTHING.
Silence is already a first-class signal on the pipe path - it means "lost", the
seq is absent from the next SACK mask, the client retransmits, and the transfer
continues.

This is a conformance fix, not a protocol change. pipe-write-protocol.md 5.2
already reserves NACKs for unrecoverable conditions, "not ordinary packet loss",
and 5.1 makes an 0x81 NACK unconditionally fatal - so today's firmware violates
the pipe spec as written. No opcode, response code, or envelope changes; no
canonical-header edit.

Deliberately narrow: tag failures keep the NACK (tamper evidence, not loss), and
0x0071 legacy DIRECT_WRITE_DATA is left alone - different ACK discipline, not
analysed here, and the field failure lives on the pipe path.

Step 6 - the stale comment at the 0x0081 case rewritten. Per [C1] it now states
the MECHANISM rather than a number: the forward gap is bounded by the client's
retransmit budget max_retx = max(3*W, n/2) and by blocks_per_ack, which live in
another repo and one of which is a user-facing Home Assistant option, so
OD_NONCE_FORWARD_CAP is a heuristic with headroom, not an invariant firmware can
prove. A number written here would be falsified silently by a client-side
config change.
Step 5 / Decision D of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. Standalone,
no framework, no PlatformIO env and no test/ directory - so the 11-env matrix
and a bare `pio run` are untouched. Includes ONLY src/nonce_window.h.

  g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address \
      tools/test_nonce_window.cpp -o /tmp/test_nonce_window && /tmp/test_nonce_window
  -> PASSED 38199 checks

Coverage:
  - Purity of od_nonce_check (D2) - the single most valuable assertion here.
    Snapshot the state, call every result class, memcmp byte-for-byte after.
    "The tag is the only thing that may advance replay state" rests on it.
  - D3 at fwd == 0: re-presenting a committed counter is REPLAY, including the
    case today's firmware exempts.
  - Fresh session: counter 0 accepted exactly once from a virgin state, with no
    has_seen_counter sentinel anywhere.
  - Shift edges: fwd = 0, 1, 63, 64, 65, 127, 128 through od_nonce_check (the
    reachable range), plus 129, 191, 192, 255, 256, 257 driven directly against
    od_nonce_commit for the word boundaries and the wholesale-clear guard.
  - Wholesale slide asserts the EXACT enum: previously-seen counters come back
    OUT_OF_WINDOW, not REPLAY. Both reject, so conflating them would be
    invisible in behaviour and would hide a genuine slide bug.
  - Bit-index invariant stated directly: the bit denoting (L - i) must sit at
    i + d under L' = L + d.
  - [M1] counter arithmetic at counter = 2^63 / last_seen = 1 and around
    UINT64_MAX including the wrap - the inputs that make the signed form UB.
    UBSan makes this self-checking.
  - Differential test against a std::set oracle, fixed mt19937_64 seed, with a
    full backward-window sweep at the end of each sequence. The oracle prunes
    counters that fall out of the window so it agrees on REPLAY vs
    OUT_OF_WINDOW, not merely on accept vs reject.

__ubsan_on_report is overridden so a UBSan report exits nonzero: UBSan defaults
to print-and-continue, which would let a reintroduced signed-arithmetic
regression pass CI with a runtime-error line nobody reads.

Mutation-checked: breaking the backward bit test, the cap comparison, the
fwd == 0 bit test, or the cross-word shift carry all fail the test.
…g, honest comments

Findings from an independent adversarial review of the Phase 1 diff against
docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. No behaviour change to the window
state machine; all 38199 host-test checks and all 11 firmware envs still pass.

1. One rate-limit budget PER LOG SITE, not one shared. A stale client spamming
   session-id mismatches could otherwise silence the out-of-window line for 5 s
   at a time - and out-of-window is precisely the condition Step 5's hardware
   tests 0-2 exist to observe. The shared budget would have masked the
   measurement the plan depends on.

2. The rejection log now prints `back` for a replay and `fwd` for out-of-window.
   A replayed counter is normally BEHIND last_seen, where the (correctly)
   wrapping fwd delta printed as a 20-digit number - unreadable in the case the
   line fires in most.

3. Decision C's "linkage enforces that only decryptCommand may commit state" is
   softened to what is actually true. nonceCheck/nonceCommit are file-static, so
   it holds for them - but encryption_state.h must include nonce_window.h for
   OD_NONCE_BITMAP_WORDS and main.h includes encryption_state.h, so the raw
   od_nonce_commit() primitive is visible in every TU next to the extern
   encryptionSession. Unavoidable while the struct needs the width macro;
   recorded rather than left as a claim the code does not support.

4. od_nonce_commit's totality comment now states its sharp edge instead of
   implying it away: a counter more than OD_NONCE_BACKWARD_BITS *behind*
   last_seen also lands in the forward branch (fwd and back are complements), so
   it clears the bitmap and REWINDS last_seen, un-seeing everything. Unreachable
   through od_nonce_check - which returns OUT_OF_WINDOW so it is never committed
   - but it is the edge to watch if a caller ever commits without checking.

5. nonce_window.h includes <stdbool.h>, so its "zero dependencies" claim also
   holds for a C translation unit.
Two comments claimed the silent drop was repaired by the normal SACK path.
That holds for a rejection inside the window; it is false past
OD_NONCE_FORWARD_CAP.

Once a frame is rejected for fwd > cap, nothing commits, so last_seen never
advances. Every retransmission re-encrypts with a fresh, higher counter
(py-opendisplay _write_pipe_frame never resends the original ciphertext), so
each one is rejected at a greater distance than the last. The session cannot
recover without re-authenticating, and the transfer stalls until the
stuck-transfer timeout releases the panel.

The cap's job is to put that state out of reach, not to make it recoverable.
Dropping silently still beats a 0x81 NACK, which is unconditionally fatal and
kills the upload immediately -- but it does not rescue the transfer, and the
comments should not have implied it did.

Comments only; no behaviour change. nrf52840custom builds.
OD_NONCE_FORWARD_CAP turned a transient link fault into a permanent session
fault. A counter more than 128 ahead was rejected, and because nothing commits
until the CCM tag verifies, last_seen never advanced. The client re-encrypts
every retransmission with a fresh, higher counter and never resends the original
ciphertext, so each subsequent frame was rejected at a greater distance than the
last. The session could not recover without re-authenticating; the transfer
stalled until the 15-minute watchdog released the panel.

It is reachable with supported settings. With W=32 and blocks_per_ack=1, 16
queued gap-ACKs at PIPE_RETX_ACK_SPACING=2 burn 128 counters on repairs alone,
and the gap accumulates across aborted attempts because the client deliberately
does not re-authenticate mid-transfer. max_retx = max(3*W, n/2) is order
thousands for a full-panel upload, so the budget is nowhere near spent when the
cap is crossed.

The cap bought nothing. All 8 counter bytes sit inside the CCM nonce, so a
tampered counter fails the tag; commit runs only on the success arm; and passing
the check mutates nothing. An attacker who cannot forge a tag could not advance
last_seen at any distance, with or without a cap -- and the session id is
cleartext in every frame, so anyone who could flood CCM with a capped window
could flood it without one.

So: no forward bound. A counter ahead of last_seen is accepted at any distance
and gated by the tag, exactly as RFC 4303 Appendix A2 does.

Comparison also moves from modular to numeric. Modular arithmetic made a counter
far behind indistinguishable from one far ahead, which is what let an ancient
counter present as an enormous forward jump -- the "sharp edge" the header
admitted, where committing one would rewind last_seen and clear the bitmap. That
is now impossible by construction rather than by the caller's contract. For the
same reason, do not reintroduce a bound as cap=UINT64_MAX or as
"not-backward implies forward": either restores the overlap.

Consequences:
- Counters no longer wrap. Reaching UINT64_MAX requires re-authentication, per
  RFC 4303 3.3.3; wrapping would reuse a (key, nonce) pair, which is the exact
  failure this file prevents. 2^64 counters in one session is unreachable.
- NONCE_OUT_OF_WINDOW now means only "too far behind", so the rejection log
  computes direction from the counters instead of inferring it from the reason,
  which would have printed an underflowed 20-digit distance.
- OD_NONCE_BACKWARD_BITS keeps its value but not its old justification, which was
  stated in terms of the cap. It is now purely out-of-order tolerance, and its
  exact value is not load-bearing: a backward rejection is self-healing, because
  the retransmit carries a higher counter that is accepted unconditionally.

Tests: the oracle no longer prunes its seen set -- that pruning encoded the
implementation's forgetting, so it could only ever agree with it. It now keeps
every counter ever committed and states the property directly: a consumed
counter is never returned NONCE_OK, at any distance, by any route. Added the
cliff as a sequence (not a point, which cap=UINT64_MAX would also pass), the
escalating-retransmit pattern, and far-behind-is-never-forward, which pins both
forbidden shortcuts. Shift edges now run through od_nonce_check, so the
wholesale-clear path is exercised the way production reaches it.

Verified: 47445 checks pass under -Werror with ASan+UBSan; the same suite fails
1635 checks against the pre-change implementation. nrf52840custom and
esp32-c3-N16 build.

No wire change: nonce format, response codes and framing are untouched, and the
accept set only grows, so no peer needs updating in lockstep.

# Conflicts:
#	.github/workflows/main.yaml
Every 0x81 NACK is terminal: it sets pipeState.error, releases the panel, and
makes the client raise. None of that left a trace in the device log, so a failed
upload could not be told apart from a stall or a link drop without a sniffer or
the client's traceback.

Logged before the send, at ERROR, with the state a diagnosis needs: the error
code, expected_seq, highest_seen, reorder-queue occupancy and the negotiated
window. It cannot flood -- pipeState.error is set immediately after and makes
every later 0x0081 frame discard, so at most one fires per session.

The comment singles out err 0x04. A conforming client cannot produce it: it only
transmits seq within W of its own window_base (all four transmit sites in
py-opendisplay's _pipe_stream are bounded by that rule), and the device's
expected_seq is provably within W of that same base, so fwd < W or back <= W
always holds and the "out of window on both sides" arm is unreachable. Client and
device agree on W because the device advertises PIPE_MAX_W in the START ACK and
both apply the same min-rule -- including on the PIPE_SMALL_DRAM_WINDOW envs
where PIPE_MAX_W is 16 rather than 32.

That proof rests on a window rule enforced in another repo and on nothing in this
firmware, which is exactly why the line is worth having: if the assumption ever
drifts, this is the only thing that would say so.

nrf52840custom, esp32-c3-N16 and esp32-N4 build.
@davelee98
davelee98 force-pushed the fix/nonce-replay-window branch from 19335e6 to e83251e Compare August 2, 2026 11:53
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