From 8bce9b7cf5094480dbee7da7b2c243b1400501ee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 17:11:36 -0400 Subject: [PATCH 1/2] Add sliding-window replay protection to encrypted commands (MJ-11) The inbound encrypted-command path only verified session liveness and the CCM tag, never the nonce counter. An attacker who captured any encrypted command frame in a live session could replay it verbatim and the device would re-execute it. The replay-window fields already existed in the session struct but were unused on the inbound path. Port the nRF firmware's verifyNonceReplay sliding-window check to Silabs and wire it into decrypt_encrypted_payload at the point the CCM tag is verified, mirroring nRF's decryptCommand semantics so both firmwares behave identically against the same client: reject replays (already-seen or below the +/-32 window), advance last_seen_counter/replay_window on accept, and clear the session after repeated integrity failures. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- opendisplay_pipe.c | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/opendisplay_pipe.c b/opendisplay_pipe.c index 720d15c..c564e11 100644 --- a/opendisplay_pipe.c +++ b/opendisplay_pipe.c @@ -355,6 +355,45 @@ static bool od_ccm_decrypt(const uint8_t *nonce, return (diff == 0u); } +static bool verify_nonce_replay(const uint8_t *nonce) +{ + uint64_t nonce_counter = 0u; + int64_t diff; + int i; + + if (!s_session.authenticated) { + return false; + } + if (memcmp(nonce, s_session.session_id, 8u) != 0) { + return false; + } + for (i = 0; i < 8; i++) { + nonce_counter = (nonce_counter << 8) | nonce[8 + i]; + } + + diff = (int64_t)nonce_counter - (int64_t)s_session.last_seen_counter; + if (diff < -32 || diff > 32) { + return false; + } + + if (nonce_counter <= s_session.last_seen_counter && diff != 0) { + for (i = 0; i < 64; i++) { + if (s_session.replay_window[i] == nonce_counter) { + return false; + } + } + } + + if (nonce_counter > s_session.last_seen_counter) { + s_session.last_seen_counter = nonce_counter; + } + + s_session.replay_window[s_session.replay_idx] = nonce_counter; + s_session.replay_idx = (uint8_t)((s_session.replay_idx + 1u) % 64u); + + return true; +} + static bool decrypt_encrypted_payload(uint16_t cmd, const uint8_t *payload, uint16_t payload_len, @@ -372,6 +411,13 @@ static bool decrypt_encrypted_payload(uint16_t cmd, return false; } nonce = payload; + if (!verify_nonce_replay(nonce)) { + s_session.integrity_failures++; + if (s_session.integrity_failures >= 3u) { + clear_session(); + } + return false; + } tag = &payload[payload_len - 12u]; cipher = &payload[16u]; cipher_len = (uint16_t)(payload_len - 16u - 12u); @@ -389,6 +435,7 @@ static bool decrypt_encrypted_payload(uint16_t cmd, if (*out_plain_len > 0u) { memmove(out_plain, &out_plain[1], *out_plain_len); } + s_session.integrity_failures = 0u; s_session.last_activity_ms = od_now_ms(); return true; } From ec0bb2513db36a654777ff666ed1b2a169a7e884 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Jul 2026 03:08:24 -0400 Subject: [PATCH 2/2] Advance replay window only after CCM tag verifies The replay counter is read from the cleartext nonce, so trusting it before the CCM tag is verified let a forged frame advance last_seen_counter and the replay window, then fail the tag. Chained forged frames (each within the +/-32 window) walk the window arbitrarily far forward, after which legitimate lower-counter frames fall outside the window and are rejected as replays -- a session denial-of-service. Split the check into two steps mirroring the nRF companion reorder (OpenDisplay/Firmware_NRF fix/replay-window-advance-after-auth): nonce_replay_check rejects replays/out-of-window nonces before decrypt but no longer mutates state; nonce_replay_advance records the counter and is called only after od_ccm_decrypt confirms the tag. A forged frame with a bad tag now returns before advancing and cannot desync the window, while a legitimate in-order frame still advances it exactly once. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- opendisplay_pipe.c | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/opendisplay_pipe.c b/opendisplay_pipe.c index c564e11..430fc20 100644 --- a/opendisplay_pipe.c +++ b/opendisplay_pipe.c @@ -355,7 +355,12 @@ static bool od_ccm_decrypt(const uint8_t *nonce, return (diff == 0u); } -static bool verify_nonce_replay(const uint8_t *nonce) +/* Reject a replayed or out-of-window nonce. Does NOT mutate the replay + * window: a frame is only recorded once its CCM tag has been verified + * (see nonce_replay_advance), so a forged frame with a bogus tag cannot + * desync the window and lock out subsequent legitimate lower-counter + * frames. On success the parsed counter is returned via out_counter. */ +static bool nonce_replay_check(const uint8_t *nonce, uint64_t *out_counter) { uint64_t nonce_counter = 0u; int64_t diff; @@ -384,14 +389,20 @@ static bool verify_nonce_replay(const uint8_t *nonce) } } + *out_counter = nonce_counter; + return true; +} + +/* Advance the replay window. Only called after the CCM tag has verified, + * so the counter belongs to an authenticated frame. */ +static void nonce_replay_advance(uint64_t nonce_counter) +{ if (nonce_counter > s_session.last_seen_counter) { s_session.last_seen_counter = nonce_counter; } s_session.replay_window[s_session.replay_idx] = nonce_counter; s_session.replay_idx = (uint8_t)((s_session.replay_idx + 1u) % 64u); - - return true; } static bool decrypt_encrypted_payload(uint16_t cmd, @@ -406,12 +417,16 @@ static bool decrypt_encrypted_payload(uint16_t cmd, uint8_t nonce_ccm[13]; uint8_t ad[2]; uint16_t cipher_len; + uint64_t nonce_counter; if (payload_len < (16u + 12u + 1u) || !session_alive()) { return false; } nonce = payload; - if (!verify_nonce_replay(nonce)) { + /* Reject replays early, but do not advance the window yet: the counter is + * taken from the cleartext nonce and must not be trusted until the CCM tag + * has authenticated the frame (see nonce_replay_advance below). */ + if (!nonce_replay_check(nonce, &nonce_counter)) { s_session.integrity_failures++; if (s_session.integrity_failures >= 3u) { clear_session(); @@ -428,6 +443,10 @@ static bool decrypt_encrypted_payload(uint16_t cmd, if (!od_ccm_decrypt(nonce_ccm, ad, cipher, cipher_len, tag, out_plain)) { return false; } + /* Tag verified: the frame is authentic, so it is now safe to advance the + * replay window. A forged frame (bad tag) never reaches this point and + * therefore cannot desync the window. */ + nonce_replay_advance(nonce_counter); if (out_plain[0] > (cipher_len - 1u)) { return false; }