Skip to content

feat(fw): answer /f/c rrcheck with a locally-forged access grant (#120) - #124

Merged
Lash-L merged 1 commit into
mainfrom
fix/rrcheck-fc-local-grant
Oct 1, 2026
Merged

Lash-L merged 1 commit into
mainfrom
fix/rrcheck-fc-local-grant

Conversation

@Lash-L

@Lash-L Lash-L commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Fixes #120. On (at least) the Qrevo Edge 2 (a298), the vacuum works for ~48 h after onboarding and then every RPC comes back as -10002 "rrcheck access denied" while MQTT stays connected. Re-onboarding restores control for another ~48 h.

Root cause

The firmware periodically calls POST /f/c to renew a local rrcheck access grant (at onboarding, daily, and an extra call exactly ~48 h after onboarding — see @novinc's request-level timeline in the issue). We had no route for /f/c, so it fell through to the generic catchall, which returns:

{"ok": true, "route": "/f/c"}

That is not a valid grant. The firmware scores every check as failed, tolerates the failures during a ~48 h grace window, then flips its RPC AccessControl state to denied. That is the -10002. Re-onboarding only resets the grace window, which is exactly the observed behaviour. This matches the RRCheck logic reconstructed from the S7-family AppProxy (48 h = the built-in 172800s default).

Fix

Add an /f/c route that forges an accepted grant locally, so the robot never has to contact the Roborock cloud. The response contract (from S7 firmware) is:

{ "k": base64(RSA-OAEP-SHA1(device_pubkey, aes128_key)),
  "d": base64(AES-128-ECB(pkcs7({"f":"t","c":<country>,"t":<unix>}))) }
  • f == "t" is the accepted flag; t must be within ±300 s of the device clock (the robot syncs its clock from our /time/now).
  • k is unwrapped by the device with the same RSA private key it uses for /region, so the public key the server already recovers during onboarding is exactly what we need. No Roborock key is involved.

That the device onboards through this server at all proves the recovered pubkey is correct and that the device uses this same RSA-OAEP-SHA1 wrapping for its bootstrap — the same wrapping /f/c's k field relies on.

Why this is safe to enable by default

The {k,d} / {f,c,t} shape and the absence of a response signature are verified only on S7 firmware. If a newer model (e.g. a298) expects a different shape or checks a signature, the device simply treats our response as another failed check — identical to the previous catchall behaviour — so this cannot regress a device that works today. If we have no recovered key for the device, we fall back to the catchall.

Changes

  • shared/bootstrap_crypto: expose BootstrapEncryptor.get_pubkey
  • shared/context: add ServerContext.device_public_key
  • https_server: new fw_check route registered just ahead of the catchall
  • tests/test_fw_check.py: play the device — build a keypair, register the modulus the way onboarding recovery does, drive the request through resolve_route, decrypt the grant, and assert f == "t"; also covers path variants and the no-key fallback

Testing

pytest -q → 291 passed locally (including the 3 new tests).

Note for testers

This needs verification on a real a298 beyond the 48 h mark with the robot fully offline. If it holds past ~48 h without re-onboarding, the fix is confirmed.

The vacuum periodically POSTs /f/c to renew a local "rrcheck" access grant.
With no route for it, the request hit the generic catchall, which returns
{"ok": true, ...} -- not a valid grant. The firmware scores every check as
failed, tolerates it for a ~48h grace period, then flips its RPC AccessControl
state to denied, after which every command returns -10002 "rrcheck access
denied" while MQTT stays connected. Re-onboarding only resets the grace window.

Add an /f/c route that forges an accepted grant using the device RSA public key
already recovered during onboarding (the same key used to encrypt /region), so
the robot never has to reach the Roborock cloud:

  {"k": b64(RSA-OAEP-SHA1(device_pubkey, aes128_key)),
   "d": b64(AES-128-ECB(pkcs7({"f":"t","c":<country>,"t":<unix>})))}

The {k,d}/{f,c,t} contract and the absence of a response signature are verified
on S7-family firmware. If a newer model expects a different shape, the response
is treated as another failed check -- identical to the old catchall behaviour --
so this cannot regress a working device. When no device key is available we fall
back to the catchall.

- shared/bootstrap_crypto: expose BootstrapEncryptor.get_pubkey
- shared/context: add ServerContext.device_public_key
- https_server: register the fw_check route ahead of the catchall
- tests: decrypt the grant end-to-end, cover path variants and the no-key fallback
@Lash-L
Lash-L merged commit 1530dfc into main Oct 1, 2026
3 checks passed
@Lash-L Lash-L mentioned this pull request Oct 8, 2026
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.

Qrevo Edge 2 (a298): rrcheck access denied after ~48h fully offline

1 participant