camera: drop client-side liveview.prepare and liveview signing

The cloud pushes liveview.prepare (with ttcode_enc) to the printer by
itself in response to POST /v1/iot-service/api/user/ttcode, so the
plugin never needs to publish it. Remove the signed prepare from
remote_camera_url, rescue_cloud_liveview and its detached republish
thread, the 84033543 frame filter, and revert the signing generalisation
(signable_root_key, ttcode -> ttcode_enc) back to print-only. This also
stops liveview frames from waiting up to 8 s on wait_for_app_cert.
This commit is contained in:
Alexey Cluster committed 2026-09-27 05:46:59 +02:00
1 parent b1dd3ab696
commit 8080cb9ab7
6 files changed
+50 -184

No files matched your search

+4 -6
View File
@@ -417,11 +417,10 @@ public:
// Studio then shows its normal "connection failed" state.
std::string camera_url_for(const std::string& dev_id);
// Remote cloud camera URL for bambu_network_get_camera_url when the LAN
// route is unavailable. Fetches TUTK credentials via /v1/iot-service/api/user/ttcode,
// proactively dispatches signed/encrypted prepare command to the printer,
// and returns "bambu:///tutk?uid=...".
// route is unavailable. Fetches TUTK credentials via /v1/iot-service/api/user/ttcode
// and returns "bambu:///tutk?uid=...". The cloud itself pushes
// liveview.prepare to the printer in response to that POST.
std::string remote_camera_url(const std::string& dev_id);
void rescue_cloud_liveview(const std::string& dev_id, const std::string& json);
// Friendly name from the last SSDP packet for this printer IP, or "".
std::string device_display_name_for_ip(const std::string& dev_ip) const;
// Bearer + optional Studio certification headers for api.bambulab.com.
@@ -470,8 +469,7 @@ private:
const std::string& json);
// Records the printer's ipcam.tutk_server status ("enable" / "disable")
// into tutk_server_ready_by_dev_ to prevent unnecessary liveview.prepare
// commands that restart a running server.
// into tutk_server_ready_by_dev_.
void harvest_tutk_server_status(const std::string& dev_id,
const std::string& json);
+8 -9
View File
@@ -8,14 +8,13 @@ typedef struct evp_pkey_st EVP_PKEY;
namespace obn::signing {
// Returns a signed envelope JSON for {"print":{...}} or {"liveview":{"command":"prepare",...}} payloads.
// Returns a signed envelope JSON for {"print":{...}} payloads.
// All other message types pass through unchanged.
//
// When `device_pub` is non-null, device-cert field encryption is applied to
// the payload object *before* signing (so the signature covers the encrypted
// form that goes on the wire): `url_enc` / `param_enc` / `ttcode_enc` are added
// for the field the command carries (url for project_file, param for gcode_line,
// ttcode for prepare).
// the `print` object *before* signing (so the signature covers the encrypted
// form that goes on the wire): `url_enc` / `param_enc` are added for the one
// field the command carries (url for project_file, param for gcode_line).
// `developer_mode` selects what happens to the cleartext field afterwards:
// false (secured) — the cleartext is DROPPED (secured firmware reads only
// *_enc and rejects a message carrying both — research/§10.3);
@@ -29,10 +28,10 @@ std::string maybe_sign(const std::string& payload_json,
bool developer_mode = false);
// True when maybe_sign() would actually sign `payload_json` — i.e. it carries a
// top-level "print" or signable "liveview" object and a slicer key is configured.
// Callers use this to gate a signed publish on the printer having installed the
// app cert this session (a secured printer rejects a signature made before install
// with 84033545 "need reset device pub key").
// top-level "print" object and a slicer key is configured. Callers use this to
// gate a signed publish on the printer having installed the app cert this
// session (a secured printer rejects a signature made before install with
// 84033545 "need reset device pub key").
bool would_sign(const std::string& payload_json);
// Blockwise RSA-PKCS#1 v1.5 encryption of `plaintext` under `pub`, returned as
+4 -5
View File
@@ -23,9 +23,9 @@ using obn::as_agent;
// fetch video over RTSP(S) :322 instead of MJPEG :6000 on X1/P1S/P2S printers.
//
// When no LAN route is known (e.g. printer remote or off-LAN), we query the
// Bambu cloud iot-service ttcode endpoint to mint a bambu:///tutk?... URL,
// proactively dispatching the signed and encrypted liveview prepare command
// so the printer starts its tutk_server.
// Bambu cloud iot-service ttcode endpoint to mint a bambu:///tutk?... URL.
// The cloud answers that POST by pushing liveview.prepare to the printer,
// which starts its tutk_server.
OBN_ABI int bambu_network_get_camera_url(void* agent,
std::string dev_id,
std::function<void(std::string)> callback)
@@ -48,8 +48,7 @@ OBN_ABI int bambu_network_get_camera_url(void* agent,
return BAMBU_NETWORK_SUCCESS;
}
// When no LAN route is known, cloud URL minting and liveview.prepare
// involves an HTTP POST and MQTT publish. Offload to a worker thread so
// Cloud URL minting is an HTTP POST. Offload to a worker thread so
// Studio's UI / MediaPlayCtrl thread returns immediately (research/08.11.1).
std::thread([a, dev_id = std::move(dev_id), serial, callback = std::move(callback)]() {
std::string cloud_url = a->remote_camera_url(dev_id);
-104
View File
@@ -1240,81 +1240,6 @@ void Agent::maybe_install_app_cert(const std::string& dev_id)
(void)request_app_cert_install(dev_id);
}
void Agent::rescue_cloud_liveview(const std::string& dev_id,
const std::string& json)
{
if (json.find("\"liveview\"") == std::string::npos) return;
if (json.find("\"prepare\"") == std::string::npos) return;
if (json.find("84033543") == std::string::npos) return;
auto root = obn::json::parse(json);
if (!root) return;
const obn::json::Value& lv_val = root->find("liveview");
if (lv_val.kind() != obn::json::Value::Kind::Object) return;
obn::json::Object lv_obj = lv_val.as_object();
auto cmd_it = lv_obj.find("command");
if (cmd_it == lv_obj.end() || !cmd_it->second.is_string()) return;
if (cmd_it->second.as_string() != "prepare") return;
auto err_it = lv_obj.find("err_code");
if (err_it == lv_obj.end()) return;
{
bool is_rejection = false;
if (err_it->second.is_number()) {
is_rejection = (err_it->second.as_int() == 84033543LL);
}
if (!is_rejection) return;
}
std::string ttcode;
auto tt_it = lv_obj.find("ttcode");
if (tt_it != lv_obj.end() && tt_it->second.is_string())
ttcode = tt_it->second.as_string();
{
std::lock_guard<std::mutex> lk(mu_);
auto it_ready = tutk_server_ready_by_dev_.find(dev_id);
if (it_ready != tutk_server_ready_by_dev_.end() && it_ready->second) {
OBN_INFO("rescue_cloud_liveview dev=%s: tutk_server already running (enable) - skipping prepare to prevent server restart",
dev_id.c_str());
return;
}
static std::map<std::string, std::chrono::steady_clock::time_point> last_rescue;
const auto now = std::chrono::steady_clock::now();
auto it = last_rescue.find(dev_id);
if (it != last_rescue.end() && now - it->second < std::chrono::seconds(2)) {
OBN_DEBUG("rescue_cloud_liveview dev=%s: rescue cooldown active (2s), skip duplicate",
dev_id.c_str());
return;
}
last_rescue[dev_id] = now;
}
OBN_INFO("rescue_cloud_liveview dev=%s ttcode=%s: intercepting unsigned rejection, signing and republishing",
dev_id.c_str(), ttcode.c_str());
lv_obj.erase("err_code");
lv_obj["sequence_id"] = obn::json::Value(obn::next_mqtt_seq_id());
obn::json::Object new_root;
new_root["liveview"] = obn::json::Value(std::move(lv_obj));
const std::string req_json = obn::json::Value(std::move(new_root)).dump();
std::string dev_id_copy = dev_id;
std::thread([this, dev_id_copy, req_json]() mutable {
int rc = send_message(dev_id_copy, req_json, /*qos=*/0);
if (rc == BAMBU_NETWORK_SUCCESS) {
OBN_INFO("rescue_cloud_liveview dev=%s: signed liveview prepare dispatched OK",
dev_id_copy.c_str());
} else {
OBN_WARN("rescue_cloud_liveview dev=%s: send_message failed rc=%d",
dev_id_copy.c_str(), rc);
}
}).detach();
}
int Agent::send_message_to_printer(const std::string& dev_id,
const std::string& json_str,
int qos)
@@ -1587,27 +1512,6 @@ std::string Agent::remote_camera_url(const std::string& dev_id)
if (tt_resp.region.empty()) tt_resp.region = "us";
// Proactively send signed and encrypted prepare command so printer starts tutk_server
{
obn::json::Object lv_obj;
lv_obj["command"] = obn::json::Value(std::string("prepare"));
lv_obj["sequence_id"] = obn::json::Value(obn::next_mqtt_seq_id());
lv_obj["ttcode"] = obn::json::Value(tt_resp.uid);
lv_obj["authkey"] = obn::json::Value(tt_resp.authkey);
lv_obj["passwd"] = obn::json::Value(tt_resp.passwd);
lv_obj["region"] = obn::json::Value(tt_resp.region);
obn::json::Object new_root;
new_root["liveview"] = obn::json::Value(std::move(lv_obj));
const std::string req_json = obn::json::Value(std::move(new_root)).dump();
OBN_INFO("camera_url(remote): dispatching liveview prepare for dev=%s uid=%s",
serial.c_str(), tt_resp.uid.c_str());
int rc = send_message(serial, req_json, /*qos=*/0);
OBN_INFO("camera_url(remote): liveview prepare dev=%s rc=%d",
serial.c_str(), rc);
}
std::string turl = obn::camera::build_tutk_url(
tt_resp.uid, tt_resp.authkey, tt_resp.passwd, tt_resp.region);
OBN_INFO("camera_url(remote): built tutk url for dev=%s uid=%.20s region=%s",
@@ -1655,7 +1559,6 @@ void Agent::notify_local_message(const std::string& dev_id, const std::string& j
harvest_developer_mode(dev_id, json);
harvest_media_caps(dev_id, json);
harvest_tutk_server_status(dev_id, json);
rescue_cloud_liveview(dev_id, json);
// LAN telemetry is authoritative: stamp the report and, on the first one,
// defer-close the cloud report subscription for this device.
@@ -2813,13 +2716,6 @@ int Agent::connect_cloud()
harvest_developer_mode(dev_id, json);
harvest_media_caps(dev_id, json);
harvest_tutk_server_status(dev_id, json);
rescue_cloud_liveview(dev_id, json);
// Drop rejected unsigned cloud liveview frames from notifying Studio UI
if (json.find("\"liveview\"") != std::string::npos &&
json.find("84033543") != std::string::npos) {
return;
}
// Mirror Bambu's plugin: the FIRST cloud report we receive
// for a device kicks off an on_printer_connected("tunnel/<id>")
+27 -37
View File
@@ -235,31 +235,24 @@ std::string rsa_pkcs1_sign_raw_b64(EVP_PKEY* pkey,
return base64_encode(sig.data(), siglen);
}
// Returns "print", "liveview" (only for prepare commands), or empty if neither.
std::string signable_root_key(const std::string& payload) noexcept
// Returns true if the payload's first JSON key is "print".
bool is_print_payload(const std::string& payload) noexcept
{
const char* p = payload.data();
const char* end = p + payload.size();
while (p < end && (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n')) ++p;
if (p >= end || *p != '{') return {};
if (p >= end || *p != '{') return false;
++p;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n')) ++p;
if (p >= end || *p != '"') return {};
++p;
const char* kstart = p;
while (p < end && *p != '"') ++p;
if (p >= end) return {};
std::string key(kstart, p - kstart);
if (key == "print") return key;
if (key == "liveview") {
if (payload.find("\"prepare\"") != std::string::npos) return key;
}
return {};
const char kKey[] = "\"print\"";
if (p + 7 > end) return false;
for (int i = 0; i < 7; ++i)
if (p[i] != kKey[i]) return false;
return true;
}
// The one field each command carries as device-cert ciphertext
// (research/10.03): project_file's model url, gcode_line's raw G-code,
// prepare's liveview ttcode.
// (research/10.03): project_file's model url, gcode_line's raw G-code.
// project_file's `param` is the plate path the firmware reads in cleartext;
// encrypting (and dropping) it makes the printer fail with 0500-4003
// (cannot parse file).
@@ -270,7 +263,6 @@ const char* encrypted_field_for(const obn::json::Object& obj)
const std::string c = cmd->second.as_string();
if (c == "project_file") return "url";
if (c == "gcode_line") return "param";
if (c == "prepare") return "ttcode";
return nullptr;
}
@@ -310,18 +302,16 @@ void encrypt_print_fields(obn::json::Object& obj, EVP_PKEY* device_pub,
obj.erase(it);
}
// Builds the command dump ({...sorted keys...}) after optional field encryption.
// Builds the print dump ({...sorted keys...}) after optional field encryption.
// Uses json_lite, whose Object type is std::map, so dump() already sorts keys.
std::string build_command_dump(const std::string& root_key,
const std::string& payload,
EVP_PKEY* device_pub,
bool developer_mode)
std::string build_print_dump(const std::string& payload, EVP_PKEY* device_pub,
bool developer_mode)
{
auto root = obn::json::parse(payload);
if (!root) return {};
const obn::json::Value& cmd = root->find(root_key.c_str());
if (cmd.kind() != obn::json::Value::Kind::Object) return {};
obn::json::Object obj = cmd.as_object(); // copy for mutation
const obn::json::Value& print = root->find("print");
if (print.kind() != obn::json::Value::Kind::Object) return {};
obn::json::Object obj = print.as_object(); // copy for mutation
encrypt_print_fields(obj, device_pub, developer_mode);
return obn::json::Value(std::move(obj)).dump();
}
@@ -342,8 +332,7 @@ static std::string json_str_escape(const std::string& s)
// Builds the complete signed envelope JSON string.
std::string build_envelope(const std::string& to_sign,
const std::string& sig_b64,
const std::string& root_key,
const std::string& dump)
const std::string& print_dump)
{
std::string out;
out.reserve(to_sign.size() + sig_b64.size() + 200);
@@ -357,8 +346,8 @@ std::string build_envelope(const std::string& to_sign,
out += json_str_escape(sig_b64);
out += "\",\"sign_ver\":\"";
out += kSignVer;
out += "\"},\"" + root_key + "\":";
out += dump;
out += "\"},\"print\":";
out += print_dump;
out += '}';
return out;
}
@@ -367,7 +356,7 @@ std::string build_envelope(const std::string& to_sign,
bool would_sign(const std::string& payload_json)
{
return !signable_root_key(payload_json).empty() && slicer_pkey() != nullptr;
return is_print_payload(payload_json) && slicer_pkey() != nullptr;
}
bool slicer_signing_key_present()
@@ -378,23 +367,24 @@ bool slicer_signing_key_present()
std::string maybe_sign(const std::string& payload_json, EVP_PKEY* device_pub,
bool developer_mode)
{
const std::string root_key = signable_root_key(payload_json);
if (root_key.empty()) return payload_json;
if (!is_print_payload(payload_json)) return payload_json;
EVP_PKEY* pkey = slicer_pkey();
if (!pkey) return payload_json;
const std::string dump =
build_command_dump(root_key, payload_json, device_pub, developer_mode);
if (dump.empty()) return payload_json; // malformed; pass through
// Encrypt url/param into url_enc/param_enc (when a device key is given)
// BEFORE signing, so the signature covers exactly what goes on the wire.
const std::string print_dump =
build_print_dump(payload_json, device_pub, developer_mode);
if (print_dump.empty()) return payload_json; // malformed; pass through
const std::string to_sign = "{\"" + root_key + "\":" + dump + "}";
const std::string to_sign = std::string("{\"print\":") + print_dump + '}';
const std::string sig_b64 = rsa_sha256_sign_b64(
pkey,
reinterpret_cast<const unsigned char*>(to_sign.data()), to_sign.size());
return build_envelope(to_sign, sig_b64, root_key, dump);
return build_envelope(to_sign, sig_b64, print_dump);
}
std::string sign_bytes(const std::string& data)
+7 -23
View File
@@ -493,30 +493,14 @@ static int test_blockwise_multiblock_roundtrip()
return 0;
}
static int test_liveview_prepare_ttcode_encrypted()
static int test_liveview_prepare_passthrough()
{
const std::string ttcode = "VGC47JEVNKCHC9RZ111A";
// The cloud pushes liveview.prepare itself after POST /user/ttcode; the
// client never signs or field-encrypts liveview frames.
const std::string payload =
R"({"liveview":{"authkey":"c0c597fc","command":"prepare","passwd":"a71c21","region":"us","sequence_id":"21143","ttcode":")" +
ttcode + R"("}})";
// Secured printer (developer_mode = false): ttcode_enc added, cleartext ttcode dropped.
const std::string env_sec = obn::signing::maybe_sign(payload, g_test_key, /*developer_mode=*/false);
auto val_sec = obn::json::parse(env_sec);
CHECK(val_sec);
CHECK(val_sec->find("header.sign_string").kind() == obn::json::Value::Kind::String);
CHECK(val_sec->find("liveview.ttcode").kind() == obn::json::Value::Kind::Null);
CHECK(val_sec->find("liveview.ttcode_enc").kind() == obn::json::Value::Kind::String);
CHECK(rsa_decrypt_blocks_b64(val_sec->find("liveview.ttcode_enc").as_string()) == ttcode);
CHECK(val_sec->find("liveview.authkey").as_string() == "c0c597fc");
CHECK(val_sec->find("liveview.passwd").as_string() == "a71c21");
// Developer Mode printer (developer_mode = true): ttcode_enc added, cleartext ttcode kept.
const std::string env_dev = obn::signing::maybe_sign(payload, g_test_key, /*developer_mode=*/true);
auto val_dev = obn::json::parse(env_dev);
CHECK(val_dev);
CHECK(val_dev->find("liveview.ttcode").as_string() == ttcode);
CHECK(val_dev->find("liveview.ttcode_enc").kind() == obn::json::Value::Kind::String);
R"({"liveview":{"command":"prepare","sequence_id":"0","ttcode":"TW3UK1278NNMSZD1111A"}})";
CHECK(!obn::signing::would_sign(payload));
CHECK(obn::signing::maybe_sign(payload, g_test_key, false) == payload);
return 0;
}
@@ -576,7 +560,7 @@ int main()
if (test_no_device_key_leaves_cleartext() != 0) rc = 1;
if (test_param_enc_idempotent() != 0) rc = 1;
if (test_blockwise_multiblock_roundtrip() != 0) rc = 1;
if (test_liveview_prepare_ttcode_encrypted() != 0) rc = 1;
if (test_liveview_prepare_passthrough() != 0) rc = 1;
if (rc == 0) std::cout << "signing_test: ok\n";