From 73a7be427d79950f744e7b43075200b4572ff35a Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 14 Sep 2026 18:52:50 -0400 Subject: [PATCH] Fix multipart V2 NC requests and prepare v1.1.0-rc2 --- docs/v2_onboarding.md | 47 +++++-- pyproject.toml | 3 +- roborock_local_server_beta_addon/CHANGELOG.md | 6 + roborock_local_server_beta_addon/DOCS.md | 22 ++- roborock_local_server_beta_addon/config.yaml | 2 +- src/roborock_local_server/__init__.py | 2 +- src/roborock_local_server/server.py | 21 ++- tests/test_nc_multipart.py | 128 ++++++++++++++++++ uv.lock | 13 +- 9 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 tests/test_nc_multipart.py diff --git a/docs/v2_onboarding.md b/docs/v2_onboarding.md index e64e9b4..9cbb6ef 100644 --- a/docs/v2_onboarding.md +++ b/docs/v2_onboarding.md @@ -1,8 +1,9 @@ -# Experimental V2 public-key recovery +# Experimental V2 onboarding -This branch adds automatic RSA public-key recovery for the V2 `GET /region` -request format observed on Saros model `roborock.vacuum.a279`. It does not yet -establish complete V2 onboarding or MQTT connectivity. +The server includes automatic RSA public-key recovery for the V2 `GET /region` +request format observed on Saros model `roborock.vacuum.a279`, plus multipart +NC form parsing in `1.1.0-rc2`. These changes do not yet establish complete V2 +onboarding or MQTT connectivity. ## Verified request format @@ -44,6 +45,31 @@ Old cache entries without version metadata cannot be automatically identified as V2. New pairing attempts can supply versioned samples. Recovery may require another pairing cycle once the public key is ready. +## Multipart NC requests + +The available Q7 sc05 `03.01.74` firmware contains an alternate RSA/V2 request +builder that sends NC parameters as multipart form fields. Before rc2, the +server applied URL-encoded parsing to that body and missed its device ID and +provisioning fields. It could log `POST /nc` while returning a plaintext reply +and leaving the vacuum's NC step missing, even with a recovered public key. + +The server now parses multipart text fields so the NC handler can select the +device's key, preserve provisioning values, and associate the request with the +vacuum. Request body bytes remain unchanged for logs and signature metadata. + +[A QRevo Edge 2 Set tester](https://github.com/Python-roborock/local_roborock_server/pull/84#issuecomment-5671286284) +reported automatic key recovery and a subsequent `POST /nc`, but no working +MQTT connection. The multipart parsing gap reproduces that reported state in +server tests. The tester's exact a298 firmware and NC body were not available, +so whether multipart parsing resolves that device's failure still needs a +hardware test. The Q7's B01 response scheme is not evidence for changing V2 +response encryption. + +Keep existing samples and recovered keys when updating to rc2. Restart the +server after updating and repeat pairing. If a key was manually inserted into +the state file while the server was running, a restart is also needed to load +it into the live encryptor. + ## Remaining hardware validation The existing V2 unsupported onboarding status remains in place. In particular, @@ -60,17 +86,22 @@ format. A real V2 device must still demonstrate: 4. Status messages, a command response, and reconnection after reboot. The next useful hardware test is a pairing attempt after successful key -recovery, checking whether the device advances from `/region` to NC and MQTT. +recovery, checking that the NC request is associated with the correct vacuum, +its `response_json.result` is an encrypted string, and MQTT traffic follows. +An HTTP 200 or recorded NC step alone does not establish that the device +accepted the response. If it stops at NC, retain the request content type, +redacted field names, response shape, and MQTT/TLS logs from that time. Further protocol changes and onboarding-status updates depend on that result. ## Regression tests ```console uv sync --extra dev -uv run pytest -q tests/test_rsa_sampling.py tests/test_v2_region_recovery.py tests/test_device_key_recovery.py tests/test_admin_api.py tests/test_runtime_state.py +uv run pytest -q tests/test_rsa_sampling.py tests/test_v2_region_recovery.py tests/test_nc_multipart.py tests/test_device_key_recovery.py tests/test_admin_api.py tests/test_runtime_state.py ``` The tests use synthetic signatures and temporary state. They cover RSA-2048 / SHA-256 compatibility, RSA-4096 / SHA-384 recovery, exact query preservation, -protocol classification, version persistence, restart behavior, and rejection -of a modified signature holdout. They do not prove physical onboarding. +protocol classification, version persistence, restart behavior, rejection of a +modified signature holdout, and NC form parsing with independently decrypted +replies and per-device onboarding state. They do not prove physical onboarding. diff --git a/pyproject.toml b/pyproject.toml index b129c12..df9d57c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roborock-local-server" -version = "1.1.0-rc1" +version = "1.1.0-rc2" description = "private local Roborock server stack." requires-python = ">=3.11,<3.14" readme = "README.md" @@ -12,6 +12,7 @@ dependencies = [ "gmpy2>=2.3,<3", "pycryptodome>=3.20,<4", "python-roborock==4.20.0", + "python-multipart>=0.0.32,<0.1", "uvicorn>=0.30,<1", ] diff --git a/roborock_local_server_beta_addon/CHANGELOG.md b/roborock_local_server_beta_addon/CHANGELOG.md index 669761a..9586899 100644 --- a/roborock_local_server_beta_addon/CHANGELOG.md +++ b/roborock_local_server_beta_addon/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.0-rc2 + +- Parse multipart NC form fields so the server can identify the vacuum, preserve its provisioning values, encrypt the reply with its recovered public key, and record the NC onboarding step. +- Keep existing Beta settings, samples, and recovered keys when updating. Retry pairing after the update; clearing state or repeating key recovery is not required for this fix. +- Full V2 onboarding and MQTT connectivity still require hardware validation. The V2 unsupported onboarding status remains in place, and the stable add-on stays on 1.0.2. + ## 1.1.0-rc1 - Added an opt-in Beta add-on with its own configuration and persistent data. The existing stable add-on stays on 1.0.2. diff --git a/roborock_local_server_beta_addon/DOCS.md b/roborock_local_server_beta_addon/DOCS.md index 095f07a..a0f214b 100644 --- a/roborock_local_server_beta_addon/DOCS.md +++ b/roborock_local_server_beta_addon/DOCS.md @@ -1,8 +1,10 @@ # Roborock Local Server Beta This is an opt-in prerelease for testing experimental changes. Version -`1.1.0-rc1` includes V2 public-key recovery verified against saved Saros a279 -requests. Complete V2 onboarding and MQTT connectivity still need hardware tests. +`1.1.0-rc2` includes V2 public-key recovery and multipart NC form parsing. +The NC fix lets the server identify the vacuum and encrypt its reply when a +recovered public key is available. Complete V2 onboarding and MQTT connectivity +still need hardware tests. The stable **Roborock Local Server** add-on remains on `1.0.2`. Installing or updating that add-on does not select this prerelease. Beta is a separate add-on, @@ -10,7 +12,7 @@ with its own settings, cloud import, keys, and persistent `/data` directory. ## First installation -1. Wait until the `v1.1.0-rc1` GitHub prerelease has published its container image. +1. Wait until the `v1.1.0-rc2` GitHub prerelease has published its container image. 2. Add the repository `https://github.com/Python-roborock/local_roborock_server` in the Home Assistant add-on store if it is not already present, then refresh the store and install **Roborock Local Server Beta**. @@ -26,7 +28,7 @@ with its own settings, cloud import, keys, and persistent `/data` directory. and perform cloud import so the test vacuum appears. Enable **Allow new app logins, onboarding, and first-time vacuum connections**. 6. On a second computer with Wi-Fi, use the onboarding scripts from the - `v1.1.0-rc1` checkout and run: + `v1.1.0-rc2` checkout and run: ```bash uv run start_onboarding.py --server YOUR_API_HOST:555 @@ -37,12 +39,24 @@ with its own settings, cloud import, keys, and persistent `/data` directory. ## What to check +When updating from rc1, keep the existing Beta data and recovered key. Update +and restart Beta, then repeat pairing. This fix does not require a state wipe or +another public-key recovery. If you inserted a key into the state file manually, +restart the server before pairing so the live encryptor loads it. + The V2 query sample counter may remain zero because V2 uses header signatures. Wait for **Public Key determined** in the admin dashboard, then perform another pairing cycle and check for NC registration, authenticated MQTT traffic, status updates, and a command response. A recovered key or HTTP 200 alone does not establish successful onboarding. The unsupported banner remains in this build. +For the NC check, look for a `POST /nc` attributed to the correct vacuum, a +string in `response_json.result` instead of a plaintext object, and an NC step +recorded for that vacuum. These show that the server handled the request; +MQTT traffic is still needed to establish that the vacuum accepted the reply. +If pairing stops at NC, retain its content type, redacted request field names, +response shape, and MQTT/TLS logs from the same time for investigation. + Keep the saved Beta data between attempts. Report the model, firmware, Beta version, recovery state, and furthest connection stage reached. Keep original logs locally and redact credentials before sharing excerpts. diff --git a/roborock_local_server_beta_addon/config.yaml b/roborock_local_server_beta_addon/config.yaml index 605054c..114def6 100644 --- a/roborock_local_server_beta_addon/config.yaml +++ b/roborock_local_server_beta_addon/config.yaml @@ -1,5 +1,5 @@ name: Roborock Local Server Beta -version: "1.1.0-rc1" +version: "1.1.0-rc2" slug: roborock_local_server_beta description: Opt-in prerelease testing of the Roborock local server, including experimental V2 key recovery. stage: experimental diff --git a/src/roborock_local_server/__init__.py b/src/roborock_local_server/__init__.py index 8a03ec7..c41efbb 100644 --- a/src/roborock_local_server/__init__.py +++ b/src/roborock_local_server/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "1.1.0-rc1" +__version__ = "1.1.0-rc2" diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index c77bda6..5c959e3 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -18,6 +18,7 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response +from python_multipart.exceptions import MultipartParseError import uvicorn from .certs import CertificateManager @@ -88,8 +89,21 @@ def _request_query_params(request: Request) -> dict[str, list[str]]: return parse_qs(request.url.query, keep_blank_values=True) -def _request_body_params(raw_body: bytes, *, content_type: str = "") -> tuple[str, dict[str, list[str]]]: +async def _request_body_params(request: Request, raw_body: bytes) -> tuple[str, dict[str, list[str]]]: body_text = raw_body.decode("utf-8", errors="replace") + content_type = str(request.headers.get("content-type") or "") + if content_type.partition(";")[0].strip().lower() == "multipart/form-data": + # The V2 request builder sends NC fields as multipart text parts. Keep + # the original body intact for capture/signature metadata. + body_params: dict[str, list[str]] = {} + try: + async with request.form(max_files=0) as form: + for name, value in form.multi_items(): + if isinstance(value, str): + body_params.setdefault(name, []).append(value) + except MultipartParseError as exc: + raise HTTPException(status_code=400, detail="Invalid multipart form data") from exc + return body_text, body_params if not body_text: return "", {} body_params = parse_qs(body_text, keep_blank_values=True) @@ -922,10 +936,7 @@ async def _handle_roborock_request(self, request: Request) -> Response: raw_body = await request.body() clean_path = strip_roborock_prefix(request.url.path) query_params = _request_query_params(request) - body_text, body_params = _request_body_params( - raw_body, - content_type=str(request.headers.get("content-type") or ""), - ) + body_text, body_params = await _request_body_params(request, raw_body) body_sha256 = hashlib.sha256(raw_body).hexdigest() is_protocol_sync_request = self._is_protocol_sync_path(clean_path) diff --git a/tests/test_nc_multipart.py b/tests/test_nc_multipart.py new file mode 100644 index 0000000..223f0f8 --- /dev/null +++ b/tests/test_nc_multipart.py @@ -0,0 +1,128 @@ +"""Exercise NC onboarding with synthetic keys and firmware-style multipart fields.""" + +import base64 +import hashlib +import json + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from fastapi.testclient import TestClient +import pytest + +from conftest import write_release_config +from roborock_local_server.config import load_config, resolve_paths +from roborock_local_server.server import ReleaseSupervisor + + +DID = "1234567890123" +DUID = "synthetic-cloud-device" +LOCAL_KEY = "0123456789abcdef" +MODEL = "roborock.vacuum.a298" + + +@pytest.fixture(scope="module") +def private_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=4096) + + +@pytest.fixture +def supervisor(tmp_path, private_key): + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + for path, payload in ( + (paths.inventory_path, {"devices": [{"duid": DUID, "model": MODEL, "local_key": LOCAL_KEY}]}), + ( + paths.runtime_credentials_path, + {"schema_version": 2, "devices": [{"did": DID, "duid": DUID, "model": MODEL, "localkey": LOCAL_KEY}]}, + ), + ( + paths.device_key_state_path, + {"devices": {DID: {"modulus_hex": format(private_key.public_key().public_numbers().n, "x")}}}, + ), + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + instance = ReleaseSupervisor(config=config, paths=paths) + instance.runtime_state.start_onboarding_session(target_duid=DUID, target_did=DID, target_name="Synthetic") + return instance + + +@pytest.mark.parametrize("prefix", ["", "/.roborock.com"]) +@pytest.mark.parametrize("encoding", ["multipart", "urlencoded"]) +def test_nc_recognizes_device_and_encrypts_reply(supervisor, private_key, prefix, encoding): + fields = { + "d": DID, + "m": MODEL, + "n": "Lab vacuum é", + "s": "synthetic+session%2B=1", + "t": "synthetic+token%2F=2", + "scheme": "1", + "extra": ["", "literal+%2B=value"], + } + # Header names overlap form names; their values must not replace the fields. + headers = {"v": "v2", "n": "nonce1", "t": "1234567890", "s": base64.b64encode(b"S" * 512).decode()} + with TestClient(supervisor.app) as client: + region = client.get(f"{prefix}/region", params={"d": DID, "m": MODEL}, headers={"v": "v2"}) + assert region.status_code == 200 + assert isinstance(region.json()["result"], str) + if encoding == "multipart": + parts = [ + (name, (None, value)) + for name, values in fields.items() + for value in (values if isinstance(values, list) else [values]) + ] + request = client.build_request("POST", f"{prefix}/nc", files=parts, headers=headers) + # Curl may quote the boundary parameter. Both forms are valid MIME. + content_type, boundary = request.headers["content-type"].split("boundary=") + request.headers["content-type"] = f'{content_type}boundary="{boundary}"' + else: + request = client.build_request("POST", f"{prefix}/nc", data=fields, headers=headers) + wire_body = request.read() + response = client.send(request) + + assert response.status_code == 200 + assert response.json()["code"] == 200 + assert isinstance(response.json()["result"], str) + ciphertext = base64.b64decode(response.json()["result"], validate=True) + assert len(ciphertext) == 512 + plain = private_key.decrypt( + ciphertext, + padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None), + ) + assert json.loads(plain) == {"d": DID, "k": LOCAL_KEY, "s": fields["s"], "t": fields["t"]} + + entries = [json.loads(line) for line in supervisor.paths.http_jsonl_path.read_text(encoding="utf-8").splitlines()] + entry = entries[-1] + assert entry["route"] == "nc_prepare" + assert entry["did"] == DID and entry["pid"] == MODEL + assert entry["body_form"]["extra"] == fields["extra"] + assert entry["body_form"]["n"] == [fields["n"]] + assert base64.b64decode(entry["body_b64"]) == wire_body + assert entry["body_sha256"] == hashlib.sha256(wire_body).hexdigest() + assert entry["response_json"] == response.json() + vacuum = next(item for item in supervisor.runtime_state.vacuum_snapshot() if item["duid"] == DUID) + assert vacuum["onboarding"]["missing_steps"] == [] + assert vacuum["onboarding"]["public_key_ready"] is True + assert supervisor.runtime_state.pairing_snapshot()["checks"]["nc"] is True + + +@pytest.mark.parametrize( + "content_type, body", + [ + ("multipart/form-data", b"missing boundary"), + ("multipart/form-data; boundary=example", b"invalid multipart body"), + ], +) +def test_invalid_multipart_is_rejected_before_nc_registration(supervisor, content_type, body): + with TestClient(supervisor.app) as client: + response = client.post("/nc", content=body, headers={"content-type": content_type}) + assert response.status_code == 400 + assert not any(event.get("route") == "nc_prepare" for event in supervisor.runtime_state.recent_events()) + + +def test_nc_multipart_rejects_file_parts(supervisor): + with TestClient(supervisor.app) as client: + response = client.post("/nc", files={"d": ("device.txt", DID)}) + assert response.status_code == 400 + assert not any(event.get("route") == "nc_prepare" for event in supervisor.runtime_state.recent_events()) diff --git a/uv.lock b/uv.lock index 081a409..4bb0deb 100644 --- a/uv.lock +++ b/uv.lock @@ -1179,6 +1179,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-roborock" version = "4.20.0" @@ -1266,7 +1275,7 @@ wheels = [ [[package]] name = "roborock-local-server" -version = "1.1.0rc1" +version = "1.1.0rc2" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1274,6 +1283,7 @@ dependencies = [ { name = "fastapi" }, { name = "gmpy2" }, { name = "pycryptodome" }, + { name = "python-multipart" }, { name = "python-roborock" }, { name = "uvicorn" }, ] @@ -1297,6 +1307,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6,<10" }, { name = "pycryptodome", specifier = ">=3.20,<4" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8,<9" }, + { name = "python-multipart", specifier = ">=0.0.32,<0.1" }, { name = "python-roborock", specifier = "==4.20.0" }, { name = "uvicorn", specifier = ">=0.30,<1" }, ]