Skip to content

Commit 3268698

Browse files
committed
fix(web_api): sign request body in Hawk auth for B01 /jobs writes
B01 /jobs writes (schedules, one-time room cleans) return 401 auth.err.invalid.token: _get_hawk_authentication only ever signs formdata in the Hawk payload slot, which is empty for a JSON body, so the MAC never covers the body the server receives. Sign md5(compact JSON body) in the payload slot for body-bearing writes, and send those same compact bytes via data= (json= would re-serialize with spaces and break the MAC). body=None keeps GET and form-post signing byte-identical. Add a create_job consumer in the existing get_* style. Fixes #849
1 parent 97ab031 commit 3268698

2 files changed

Lines changed: 87 additions & 4 deletions

File tree

roborock/web_api.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22
import hashlib
33
import hmac
4+
import json
45
import logging
56
import math
67
import secrets
@@ -646,6 +647,31 @@ async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeD
646647
else:
647648
raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
648649

650+
async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict:
651+
"""Create a /jobs entry (schedule or one-time room clean) on a B01 device.
652+
653+
Body-bearing writes must sign the request body in the Hawk payload slot and send those same
654+
compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC.
655+
"""
656+
rriot = user_data.rriot
657+
if rriot is None:
658+
raise RoborockException("rriot is none")
659+
if rriot.r.a is None:
660+
raise RoborockException("Missing field 'a' in rriot reference")
661+
path = f"/user/devices/{device_id}/jobs"
662+
job_request = PreparedRequest(
663+
rriot.r.a,
664+
self.session,
665+
{
666+
"Authorization": _get_hawk_authentication(rriot, path, body=job),
667+
"Content-Type": "application/json",
668+
},
669+
)
670+
response = await job_request.request("post", path, data=_compact_json(job).encode())
671+
if not response.get("success"):
672+
raise RoborockException(response)
673+
return response
674+
649675
async def get_products(self, user_data: UserData) -> ProductResponse:
650676
"""Gets all products and their schemas, good for determining status codes and model numbers."""
651677
base_url = await self.base_url
@@ -738,11 +764,25 @@ def _process_extra_hawk_values(values: dict | None) -> str:
738764
return hashlib.md5("&".join(result).encode()).hexdigest()
739765

740766

741-
def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = None, params: dict | None = None) -> str:
767+
def _compact_json(body: dict) -> str:
768+
"""Serialize a JSON body to the exact compact bytes that are both signed and sent."""
769+
return json.dumps(body, separators=(",", ":"))
770+
771+
772+
def _get_hawk_authentication(
773+
rriot: RRiot,
774+
url: str,
775+
formdata: dict | None = None,
776+
params: dict | None = None,
777+
body: dict | None = None,
778+
) -> str:
742779
timestamp = math.floor(time.time())
743780
nonce = secrets.token_urlsafe(6)
744-
formdata_str = _process_extra_hawk_values(formdata)
745781
params_str = _process_extra_hawk_values(params)
782+
if body is not None:
783+
payload_str = hashlib.md5(_compact_json(body).encode()).hexdigest()
784+
else:
785+
payload_str = _process_extra_hawk_values(formdata)
746786

747787
prestr = ":".join(
748788
[
@@ -752,7 +792,7 @@ def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = Non
752792
str(timestamp),
753793
hashlib.md5(url.encode()).hexdigest(),
754794
params_str,
755-
formdata_str,
795+
payload_str,
756796
]
757797
)
758798
mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode()

tests/test_web_api.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

99
from roborock import HomeData, HomeDataRoom, HomeDataScene, UserData
1010
from roborock.exceptions import RoborockAccountDoesNotExist, RoborockException, RoborockInvalidCredentials
11-
from roborock.web_api import IotLoginInfo, PreparedRequest, RoborockApiClient, UserWebApiClient
11+
from roborock.web_api import (
12+
IotLoginInfo,
13+
PreparedRequest,
14+
RoborockApiClient,
15+
UserWebApiClient,
16+
_compact_json,
17+
_get_hawk_authentication,
18+
)
1219
from tests.mock_data import HOME_DATA_RAW, USER_DATA
1320

1421
pytest_plugins = [
@@ -377,6 +384,42 @@ async def test_get_schedules(mock_rest) -> None:
377384
assert schedule.enabled is True
378385

379386

387+
async def test_create_job(mock_rest) -> None:
388+
"""A /jobs write (schedule or one-time clean) POSTs the body and returns the parsed result."""
389+
api = RoborockApiClient(username="test_user@gmail.com")
390+
ud = await api.pass_login("password")
391+
392+
mock_rest.post(
393+
"https://api-us.roborock.com/user/devices/123456/jobs",
394+
status=200,
395+
payload={"api": None, "result": "done", "status": "ok", "success": True},
396+
)
397+
398+
job = {"cron": "05 10 * * ?", "repeated": True, "enabled": True, "param": {"rooms": [1, 2], "roomCount": 2}}
399+
response = await api.create_job(ud, "123456", job)
400+
assert response["success"] is True
401+
assert response["result"] == "done"
402+
403+
404+
def test_hawk_authentication_signs_body(monkeypatch) -> None:
405+
"""Body-bearing writes sign md5(compact JSON body) in the Hawk payload slot; GET is unchanged."""
406+
from roborock import web_api
407+
408+
monkeypatch.setattr(web_api.time, "time", lambda: 1_700_000_000)
409+
monkeypatch.setattr(web_api.secrets, "token_urlsafe", lambda n: "fixednonce")
410+
rriot = UserData.from_dict(USER_DATA).rriot
411+
assert rriot is not None
412+
413+
body = {"b": 2, "a": 1}
414+
signed = _get_hawk_authentication(rriot, "/p", body=body)
415+
unsigned = _get_hawk_authentication(rriot, "/p")
416+
formdata_signed = _get_hawk_authentication(rriot, "/p", formdata=body)
417+
418+
assert signed != unsigned # the body is actually covered by the MAC
419+
assert signed != formdata_signed # body-signing differs from formdata-signing
420+
assert _compact_json(body) == '{"b":2,"a":1}'
421+
422+
380423
@pytest.mark.parametrize(
381424
"result_payload",
382425
[

0 commit comments

Comments
 (0)