You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d7308b8
Browse filesBrowse the repository at this point in the historyBrowse files
docs(nonce): record the Decision A reversal; restore the surviving citations
d65696f brought the Phase 1 planning doc onto this branch. It had been absent
from every branch, so aef3a6b stripped the citations pointing at it as dangling.
Now that it exists they belong back -- but not all of them, because aef3a6b
reversed one of the decisions it records.
Doc: Decision A ("forward cap 128") is struck through and marked REVERSED, with a
pointer to a new section at the end that carries the argument -- what the cap did
(nothing commits, so last_seen never advances, and each retransmission's fresh
higher counter is rejected further out than the last), why no number could have
been safe (the ceiling is the client's retransmit budget scaled by a user-facing
HA option, and it accumulates across aborted attempts), why removing it costs
nothing (the counter is inside the CCM nonce; commit is post-tag; the session id
is cleartext so the DoS argument never held), and what replaced it. A table
records the effect on every other item: B, C, D and E stand; Step 6's "write the
mechanism, not a number" is now literal; Step 5's hardware tests 1/2/2c are
obsolete as written, since they existed to validate the 128.
Worth noting the doc had already flagged OD_NONCE_FORWARD_CAP as "unvalidated
against a real link" in its own unverified-on-hardware list. The flaw was found
by analysis rather than by the bench it was waiting on, so that list is unchanged
by this reversal.
Code: restored the citations that still hold -- Decision D at nonce_window.h and
the CI job and the host test, Decision B for the shifting-bitmap representation
(unchanged; only the arithmetic over it moved), Step 1 at encryption.cpp and
encryption_state.h. The two sites that used to cite Decision A now cite the
reversal instead, so a reader lands on the argument that supersedes it rather
than on the superseded one.
Comments only; no behaviour change. 47445 host checks pass; nrf52840custom builds.
@@ -975,3 +982,102 @@ Everything below is still open:
975
982
does HA recover cleanly from that disconnect (as opposed to from the `0xFE` it would otherwise
976
983
have seen)? Per the pipe-window arithmetic above this **will** happen with default settings.
977
984
- Does `55a2478`'s `AUTH_REQUIRED` actually drive HA's reauth flow end to end?
985
+
986
+
---
987
+
988
+
# Reversal of Decision A — the forward cap was removed (2026-07-31)
989
+
990
+
**Commit `aef3a6b`, on `fix/nonce-replay-window` (rebased onto the squashed `#132`/`#133`/`#134`
991
+
`main`).** Decision A is reversed. `OD_NONCE_FORWARD_CAP` no longer exists, and comparison moved
992
+
from modular to numeric ordering. Decisions B, C and D stand.
993
+
994
+
## What the cap did
995
+
996
+
The cap did not merely fail to help — it converted a transient link fault into a permanent session
997
+
fault. Once a gap exceeded 128:
998
+
999
+
1.`od_nonce_check()` returns `NONCE_OUT_OF_WINDOW`, so nothing commits.
1000
+
2.`last_seen_counter` therefore never advances — that is the D2 fix working as designed.
1001
+
3. The client re-encrypts every retransmission with a **fresh, higher** counter and never resends
1002
+
the original ciphertext (`_write_pipe_frame`, `device.py:2683`), so the next frame is rejected at
1003
+
a *greater* distance than the last.
1004
+
4. Every subsequent frame is rejected, forever. Only re-authentication recovers, and the client
1005
+
deliberately does not re-authenticate mid-transfer (`device.py:778`). The transfer stalls until
1006
+
the 15-minute stuck-transfer watchdog releases the panel.
1007
+
1008
+
Step 4b's silent drop does not rescue this. Dropping silently avoids the immediate fatal teardown a
1009
+
`0x81` NACK would cause, but the transfer is dead either way.
1010
+
1011
+
## Why the cap could not be sized
1012
+
1013
+
Decision A's own framing was right and is what condemns it: the ceiling is the client's retransmit
1014
+
budget `max_retx = max(3*W, n/2)`, scaled by `blocks_per_ack`, a user-facing Home Assistant option
1015
+
in another repo. That is order thousands for a full-panel upload — and it **accumulates across
1016
+
aborted attempts**, because the client's counter keeps climbing while `last_seen` is frozen. With
1017
+
`W = 32` and `blocks_per_ack = 1`, 16 queued gap-ACKs at `PIPE_RETX_ACK_SPACING = 2` burn 128
1018
+
counters on repairs alone. No firmware-side number is defensible.
1019
+
1020
+
## Why removing it costs nothing
1021
+
1022
+
The cap was vestigial once D2 landed. All 8 counter bytes sit inside the CCM nonce, so a tampered
1023
+
counter changes the keystream and fails the tag; `nonceCommit()` runs only on the success arm; and
1024
+
passing the check mutates nothing. An attacker who cannot forge a tag could not advance `last_seen`
1025
+
at any distance, cap or no cap. Nor is it DoS protection: the session id is cleartext in every
1026
+
frame, so anyone able to flood CCM with a capped window could flood it without one.
1027
+
1028
+
## What replaced it
1029
+
1030
+
Numeric ordering, matching RFC 4303 Appendix A2, which likewise has no forward bound:
1031
+
1032
+
```
1033
+
counter == last_seen -> bit 0 set ? REPLAY : OK
1034
+
counter > last_seen -> OK (any distance; the tag is the gate)
1035
+
counter < last_seen, back<256 -> bit[back] set ? REPLAY : OK
1036
+
counter < last_seen, back>=256 -> OUT_OF_WINDOW
1037
+
```
1038
+
1039
+
Consequences worth recording:
1040
+
1041
+
-**Modular arithmetic is gone.** It made a counter far behind indistinguishable from one far
1042
+
ahead, which is what allowed an ancient counter to present as an enormous forward jump — the
1043
+
"sharp edge" the old header admitted, where committing one would rewind `last_seen` and clear the
1044
+
bitmap. That is now impossible by construction rather than by the caller's contract. **Do not**
1045
+
reintroduce a bound as `cap = UINT64_MAX` or as "not-backward implies forward": either restores
1046
+
the overlap. `test_far_behind_is_never_forward()` pins both.
1047
+
-**Counters no longer wrap**, per RFC 4303 §3.3.3. Reaching `UINT64_MAX` requires
1048
+
re-authentication; wrapping would reuse a `(key, nonce)` pair. Unreachable in practice.
1049
+
-**`NONCE_OUT_OF_WINDOW` now means only "too far behind."** The rejection log computes direction
1050
+
from the counters instead of inferring it from the reason, which would print an underflowed
1051
+
20-digit distance.
1052
+
-**`OD_NONCE_BACKWARD_BITS` keeps its value but loses its old justification**, which was stated in
1053
+
terms of the cap ("kept strictly greater than `OD_NONCE_FORWARD_CAP`"). It is now purely
1054
+
out-of-order tolerance, and its exact value is not load-bearing: a backward rejection is
1055
+
self-healing, because the retransmit carries a higher counter that is accepted unconditionally.
1056
+
1057
+
## Effect on the rest of this document
1058
+
1059
+
| Item | Status |
1060
+
|---|---|
1061
+
|**Decision A**|**Reversed.** No forward cap. |
1062
+
|**Decision B** — shifting bitmap, `uint64_t[4]`, IPsec/DTLS style |**Stands.** The representation is unchanged; only the arithmetic over it moved from modular to numeric. |
1063
+
|**Decision C** — file-static wrappers |**Stands**, including its as-built correction. |
1064
+
|**Decision D** — standalone host test + separate CI job |**Stands.**|
1065
+
|**Decision E** — no wire change |**Stands.** This reversal changes no byte on the wire: the accept set only grows, so no peer needs updating in lockstep. |
1066
+
|**Step 6** — "write the mechanism, not a number" |**Stands, and is now literal**: there is no number to write. The `communication.cpp` comment records why there cannot be one. |
1067
+
|**Step 5 hardware tests 1, 2, 2c**|**Obsolete as written.** They existed to validate the 128 figure. What replaces them is confirming a transfer survives a gap that *would* have crossed it. |
1068
+
|**"Unverified on hardware"**| Still accurate, and this reversal did not change it: the cap was condemned by analysis, not by the bench. |
1069
+
1070
+
## Verification
1071
+
1072
+
Host suite: **47445 checks** pass under `-Werror` with ASan+UBSan. The same suite run against the
1073
+
pre-change implementation fails **1635** checks, which is the evidence that the new tests
1074
+
discriminate rather than merely pass. Added `test_forward_gap_is_not_a_cliff()` (the defect as a
1075
+
*sequence*, since a point test at `last_seen+129` would also pass under `cap = UINT64_MAX`),
1076
+
`test_far_behind_is_never_forward()`, and `od_nonce_never_accepts_consumed()` — a sweep over every
1077
+
counter ever committed, which is the one assertion in the file that does not restate the code. The
1078
+
oracle no longer prunes its seen set; that pruning encoded the implementation's forgetting and so
0 commit comments