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
92 changes: 92 additions & 0 deletions docs/device-info.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Firmware device information uploads

`POST /devices/{did}/info` uploads the vacuum's serial number, model and feature
flags. This is a firmware endpoint; it is separate from the app's
`GET /user/devices/{duid}` endpoint.

The server now selects the `device_info` handler instead of `catchall`. The
previous generic HTTP 200 already acknowledged the upload on the firmware
inspected below. There is no evidence that this fallback blocked onboarding or
camera operation. The missing behavior was retaining the uploaded metadata.

## Handling and authentication

The verified firmware signs this exact ASCII form, in this order, using its
device RSA private key with PKCS#1 v1.5 and SHA-256:

```text
did=<numeric DID>&featureset=<decimal flags>&newfeatureset=<hex flags>&pid=<model>&sn=<serial>
```

It appends a URL-escaped base64 `signature`. The server checks the signature
against the device public key recovered during onboarding and checks that the
body DID matches the URL DID. Only unambiguous reports with one value per field
are persisted. Query parameters do not supply or override metadata.

This handler accepts exactly these six fields, nonempty metadata values of
1–128 ASCII letters, digits, dots, underscores or hyphens, decimal `featureset`,
and hexadecimal `newfeatureset`. These conservative limits match the observed
reports; authentic firmware variants outside them are acknowledged and logged as
unsupported without writing. They require further evidence before expanding the
accepted contract.

For an existing inventory device matched by DID, or by its runtime DID-to-DUID
link, verified reports update `sn`, `featureSet`, and `newFeatureSet` in
`web_api_inventory.json`. Hex feature flags stay as strings so leading zeroes
survive. The signature is not copied into inventory. Names, keys, product IDs and device
identities are preserved. Firmware `pid` is a model name in the inspected build;
it must not be assigned to the app's opaque `productId`.

Unknown devices are not created by this endpoint. Missing keys, unsupported
signatures, duplicate fields, malformed values and invalid signatures cause no
inventory changes. Their uploads still receive HTTP 200, preserving the old
acknowledgement behavior. Responses retain the previous fallback body, including
`data` and `result` containing `{"ok": true, "route": "/devices/{did}/info"}`.
The inspected G10S firmware ignores that body; preserving it avoids changing the
response contract for other models whose callback has not been audited.

There is no nonce or timestamp in this form. A valid recorded report can be
replayed; signature verification establishes its origin, not its freshness.
No replayed report can change identity or credentials through this handler.

The server logs the DID, an outcome (`stored`, `unchanged`, `unmatched`,
`malformed_fields`, `did_mismatch`, `unsupported_values`, `missing_key`, or
`invalid_signature`, or `write_failed`) and the number of records updated for each upload. These
diagnostics omit serial numbers, signatures and credentials.

Devices already acknowledged by the old fallback may not upload again until
their metadata changes. The inspected firmware saves a digest on success; whether
that digest survives a reboot has not been characterized. For devices with a
cloud snapshot, existing app response behavior still prefers snapshot serial and
feature fields over this inventory metadata.

## Evidence and limits

The local G10S `rriot_rr` ELF (SHA-256
`27cf67a4a03920e31647e677d8296bcf4f915801e1800d6db9527ccfe69ae752`)
provides these AArch64 virtual addresses:

| Address | Observed behavior |
| --- | --- |
| `0x16810–0x16834` | Formats the five metadata fields in the order above. |
| `0x16850–0x16894` | Calls signer `0x6010`, URL-escapes signature, appends it. |
| `0x5970–0x5a24` | SHA256 followed by RSA_sign with NID 672 (`sha256`). |
| `0x1696c–0x169a8` | Formats `/devices/%s/info`, starts POST with callback `0x16f60`. |
| `0x4bf8–0x4c60` | HTTP worker accepts status 200 or 201, returns success. |
| `0x4f44–0x4f54` | Passes worker status and response buffer to callback. |
| `0x16fec–0x1706c` | Callback checks worker status only, marks upload done and writes the metadata digest; does not parse the response. |

The S7 `rriot_rr` ELF (SHA-256
`824b3128856a3017085b391d690f210420be4e8023365a6e22597bc0f460ba3d`)
also contains the same five-field form and endpoint at file offsets `0x28730`
and `0x288c0`. Its callback has not been audited here.

All three persisted Qrevo MaxV uploads (October 1 and October 9, 2026 local time)
were independently verified with its recovered RSA public key against this
exact form and PKCS#1 v1.5/SHA-256 contract. No raw signatures, serial numbers or
keys are included here. This confirms the Qrevo's request authentication; its
response callback has not been disassembled. The handler preserves
acknowledgement for other contracts and refuses metadata writes when verification
fails. A cloud response capture is unnecessary
for the inspected G10S callback, but would be needed to describe the cloud's exact
response body or another model's response parser.
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
from .routes.api.v5.product import match as _match_get_products_v5
from .routes.bootstrap.catchall import build as _build_catchall
from .routes.bootstrap.catchall import match as _match_catchall
from .routes.bootstrap.device_info import build as _build_device_info
from .routes.bootstrap.device_info import match as _match_device_info
from .routes.fw.check import build as _build_fw_check
from .routes.fw.check import match as _match_fw_check
from .routes.bootstrap.location import build as _build_location
Expand Down Expand Up @@ -604,6 +606,7 @@ def default_endpoint_rules() -> Sequence[EndpointRule]:
EndpointRule("time", _match_time, _build_time),
EndpointRule("location", _match_location, _build_location),
EndpointRule("fw_check", _match_fw_check, _build_fw_check),
EndpointRule("device_info", _match_device_info, _build_device_info),
EndpointRule("catchall", _match_catchall, _build_catchall),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Acknowledge signed firmware metadata uploads and update known devices.

The G10S rriot_rr upload callback accepts HTTP 200/201 and ignores the response
body. Its form is RSA PKCS#1 v1.5/SHA-256 signed in the field order below. See
docs/device-info.md for firmware evidence and the limits of this contract.
"""

from __future__ import annotations

import base64
import logging
import re
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding

from shared.context import ServerContext
from shared.inventory_io import WEB_API_INVENTORY_FILE, inventory_transaction, load_inventory, write_inventory

from .catchall import build as _build_catchall

_PATH = re.compile(r"/devices/([0-9]+)/info/?")
_FIELDS = ("did", "featureset", "newfeatureset", "pid", "sn")
_SAFE_VALUE = re.compile(r"[A-Za-z0-9._-]{1,128}")
_LOGGER = logging.getLogger(__name__)


def match(path: str, method: str = "POST") -> bool:
return method.upper() == "POST" and _PATH.fullmatch(path) is not None


def _verified_report(
ctx: ServerContext, params: dict[str, list[str]], did: str
) -> tuple[dict[str, str] | None, str]:
# Duplicates and extra fields would make reconstructing the signed message
# ambiguous. Unsupported forms retain the acknowledgement but cannot write.
if set(params) != {*_FIELDS, "signature"} or any(len(v) != 1 for v in params.values()):
return None, "malformed_fields"
fields = {name: params[name][0] for name in _FIELDS}
if fields["did"] != did:
return None, "did_mismatch"
if any(_SAFE_VALUE.fullmatch(v) is None for v in fields.values()):
return None, "unsupported_values"
if not fields["featureset"].isdigit() or re.fullmatch(r"[0-9a-fA-F]+", fields["newfeatureset"]) is None:
return None, "unsupported_values"
key = ctx.device_public_key(did)
if key is None:
return None, "missing_key"
canonical = "&".join(f"{name}={fields[name]}" for name in _FIELDS)
try:
signature = base64.b64decode(params["signature"][0], validate=True)
key.verify(signature, canonical.encode("ascii"), padding.PKCS1v15(), hashes.SHA256())
except (InvalidSignature, ValueError, TypeError):
return None, "invalid_signature"
return fields, "verified"


def build(
ctx: ServerContext,
_query_params: dict[str, list[str]],
body_params: dict[str, list[str]],
clean_path: str,
) -> dict[str, Any]:
path_match = _PATH.fullmatch(clean_path)
assert path_match is not None # Route matcher already checked the path.
did = path_match.group(1)
report, outcome = _verified_report(ctx, body_params, did)
updated_count = 0
if report is not None:
identities = {did}
if ctx.runtime_credentials is not None:
device = ctx.runtime_credentials.resolve_device(did=did)
if device and device.get("duid"):
identities.add(device["duid"])
with inventory_transaction(ctx.http_jsonl.parent / WEB_API_INVENTORY_FILE):
inventory = load_inventory(ctx)
matched_count = 0
for collection in ("devices", "received_devices", "receivedDevices"):
devices = inventory.get(collection)
if not isinstance(devices, list):
continue
for device in devices:
if not isinstance(device, dict):
continue
if not any(str(device.get(key, "")) in identities for key in ("did", "duid")):
continue
matched_count += 1
update: dict[str, Any] = {
"sn": report["sn"],
"featureSet": report["featureset"],
"newFeatureSet": report["newfeatureset"],
}
# Inventory readers also accept snake_case fields, with some
# readers preferring them. Keep existing aliases consistent.
for alias, field in (("feature_set", "featureset"), ("new_feature_set", "newfeatureset")):
if alias in device:
update[alias] = report[field]
if any(device.get(key) != value for key, value in update.items()):
device.update(update)
updated_count += 1
outcome = "unchanged" if matched_count else "unmatched"
if updated_count:
if write_inventory(ctx, inventory):
outcome = "stored"
else:
outcome, updated_count = "write_failed", 0
_LOGGER.info("Device info did=%s outcome=%s updated=%d", did, outcome, updated_count)
# No response data is consumed by the verified firmware. Continue accepting
# unsupported/unverifiable reports without allowing them to alter inventory.
return _build_catchall(ctx, _query_params, body_params, clean_path)
Loading
Loading