Skip to content

B01 /jobs writes (schedules + one-time room cleans) 401 — Hawk auth signs path only; POST/PUT/DELETE must sign the request body #849

@andrewlyeats

Description

@andrewlyeats

B01 /jobs writes 401 because Hawk auth omits the request-body hash

Device: Roborock Q10 S5+ (roborock.vacuum.ss07, B01 protocol)
Source: empirical — captured the official app's HTTPS traffic and reproduced its Hawk MAC byte-for-byte, June 2026

Summary

For B01 devices, the schedule/clean REST API lives at /user/devices/{duid}/jobs. GET works, but every write (POST/PUT/DELETE) returns 401 auth.err.invalid.token. This is not a token-scope problem — the same credentials read fine. The cause is the Hawk signature: the library signs the request path only and leaves the body-hash slot empty, which is correct for GET but wrong for writes. The app signs md5(compact_json(body)) in that slot for writes. Once the body hash is included, the identical credentials write successfully.

This was a long-standing red herring (it looks exactly like a read-only token) — worth documenting so others don't chase token scope.

Where it is

web_api.py's Hawk helper (the _get_hawk_authentication/request-signing path used by e.g. execute_scene) builds the normalized request string with the hash/ext (body) field empty for all methods. Hawk's normalized string includes a payload-hash position; for a write, that position must contain the base64 (or, here, the hex md5) digest of the exact request body. GET legitimately uses an empty payload hash, which is why reads have always worked and writes have always 401'd.

How to reproduce / how we confirmed it

  • GET /user/devices/{duid}/jobs with a normal logged-in credential → 200.
  • PUT/POST/DELETE the same path → 401 auth.err.invalid.token, regardless of token freshness or re-login.
  • Captured the app performing the same write and recomputed its Hawk mac. It reproduces exactly when, and only when, the signed payload slot = md5(compact_json(body)) (compact = json.dumps(body, separators=(",", ":"))). Path-only and k=v form-encoded payloads both fail to reproduce the MAC.

The fix (two halves, both required)

  1. Sign the body. Put md5 of the compact JSON (separators=(",", ":"), no spaces) into the Hawk payload-hash position for POST/PUT/DELETE. Keep it empty for GET/HEAD.
  2. Send the exact same bytes you signed. Don't hand the dict to the HTTP client's json= serializer — it re-serializes with spaces, so the server's hash of the received body won't match your signature. Send data=<the same compact bytes> with Content-Type: application/json.

Because the signed string and the wire body are byte-identical, the server's body-hash check passes regardless of key ordering.

What this unblocks for B01

  • Scheduling writes: create / enable / disable / delete cron jobs at /jobs.
  • One-time room-targeted cleans: a one-time job (repeated: false, dated cron a minute or two out) with param.rooms: [...] is effectively "clean these rooms now" — the practical room-clean path for B01, which has no working MQTT segment-clean command exposed to third-party clients.

Job body schema (captured):

{ cron, repeated, enabled,
  param: { mapId, rooms:[...], roomCount, cleanMode, cleanRoute, fanLevel, waterLevel, cleanCount } }

(One gotcha: the REST fanLevel scale differs from the MQTT fan enum at the top tier — the max tier is contiguous on REST, not the MQTT enum's value.)

Happy to open a PR implementing the body-signing path (GET unchanged; POST/PUT/DELETE sign + send compact body) and a small test that reproduces the app's MAC.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions