Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions docs/v2_onboarding.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
]

Expand Down
6 changes: 6 additions & 0 deletions roborock_local_server_beta_addon/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
22 changes: 18 additions & 4 deletions roborock_local_server_beta_addon/DOCS.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
# 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,
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**.
Expand All @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion roborock_local_server_beta_addon/config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/roborock_local_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

__all__ = ["__version__"]

__version__ = "1.1.0-rc1"
__version__ = "1.1.0-rc2"
21 changes: 16 additions & 5 deletions src/roborock_local_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
128 changes: 128 additions & 0 deletions tests/test_nc_multipart.py
Original file line number Diff line number Diff line change
@@ -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())
13 changes: 12 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.