Skip to content

Reassemble RSCP frames split across multiple socket reads - #146

Open
TommiG1 wants to merge 1 commit into
fsantini:masterfrom
TommiG1:fix/rscp-frame-reassembly
Open

Reassemble RSCP frames split across multiple socket reads#146
TommiG1 wants to merge 1 commit into
fsantini:masterfrom
TommiG1:fix/rscp-frame-reassembly

Conversation

@TommiG1

@TommiG1 TommiG1 commented Aug 2, 2026

Copy link
Copy Markdown

Problem

E3DC_RSCP_local._receive() performs a single socket.recv(BUFFER_SIZE) call and assumes the entire encrypted RSCP frame arrives in that one read:

def _receive(self):
    data = self.socket.recv(BUFFER_SIZE)
    if len(data) == 0:
        raise RSCPKeyError
    decData = rscpDecode(self.encdec.decrypt(data))[0]
    return decData

TCP gives no such guarantee. For larger responses this can fail with:

struct.error: unpack requires a buffer of 4216 bytes

This was reported while testing an integration that reads EH_REQ_GET_SAVED_ERRORS (saved error history) — the response size scales with the number of stored history entries, so it's one of the few requests likely to exceed what a single recv() reliably returns, especially on systems with an extensive error history. See torbennehmer/hacs-e3dc#373 for context.

Fix

_receive() now loops over socket.recv() calls until the frame length declared in the RSCP header has actually been received, instead of trusting a single read.

One subtlety: RSCPEncryptDecrypt.decrypt() is stateful across calls (it tracks left-over partial AES blocks and IV chaining via self.oldDecrypt), but its default (previouslyProcessedData=None) bookkeeping only accounts for how much of the immediately preceding chunk was consumed. That's only correct for exactly two calls; from a third call onward, any non-block-aligned leftover from before the previous chunk silently shifts the accounting and corrupts the decrypted output (verified locally with a reproduction using many small, non-block-aligned reads).

To stay correct regardless of how many reads are needed, each iteration re-decrypts the whole accumulated ciphertext from scratch using a throwaway copy.copy() of the encrypt/decrypt state, just to check whether a full frame is available yet. Once it is, the real, persistent self.encdec state is advanced exactly once via a normal decrypt() call on the full ciphertext — identical to what happens today when a response arrives in a single read, so per-session IV chaining for subsequent requests is unaffected.

Testing

No formal test suite exists for this module, so I verified locally against a synthetic RSCP frame (a large EH_REQ_GET_SAVED_ERRORS-style response, ~300 rows) fed through a real socket in small (500 byte), non-block-aligned chunks, comparing against the reference single-shot decryption:

  • Fragmented, multi-read response (the bug scenario): decodes correctly and matches the reference plaintext byte-for-byte.
  • Normal small single-read response: unaffected, decodes identically to before.
  • black, isort, flake8 (incl. flake8-pyproject/max-line-length = 88) all pass on the changed file.
  • pyright reports no new issues (the one pre-existing hint about int | None syntax on line 53 is unrelated, from running under Python 3.9 locally; CI targets 3.10+).

Compatibility

No public API changes. _receive() remains a private method with the same signature and return type; behavior for normal-sized responses that already fit in one read is unchanged (single loop iteration, same decrypt path).

E3DC_RSCP_local._receive() performed a single socket.recv(BUFFER_SIZE)
call and assumed the entire encrypted RSCP frame arrived in that one
read. TCP gives no such guarantee: larger responses (e.g. an extensive
EH_REQ_GET_SAVED_ERRORS saved-error history) can be split across
multiple reads, which crashed rscpFrameDecode() with:

    struct.error: unpack requires a buffer of N bytes

_receive() now loops over recv() calls until the frame length declared
in the header has been fully received. Each iteration re-decrypts the
accumulated ciphertext from scratch on a throwaway copy of the
encrypt/decrypt state (RSCPEncryptDecrypt.decrypt()'s default
incremental bookkeeping only tracks the immediately preceding chunk
and silently corrupts data across three or more reads), and only
commits the real, persistent encdec state once the full frame is
known to be available -- matching exactly what happens today when a
response arrives in a single read.
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