// Classifies, signs, and wraps outbound MQTT payloads. // Only {"print":{...}} messages receive an envelope; all others pass through. // Envelope: {"header":{"cert_id":"...","payload_len":N,"sign_alg":"RSA_SHA256", // "sign_string":"...","sign_ver":"v1.0"},"print":{...sorted keys...}} #include "obn/signing.hpp" #include "obn/config.hpp" #include "obn/json_lite.hpp" #include "obn/log.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace obn::signing { namespace { struct PkeyDel { void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); } }; struct MdDel { void operator()(EVP_MD_CTX* p) const { EVP_MD_CTX_free(p); } }; struct CtxDel { void operator()(EVP_PKEY_CTX* p) const { EVP_PKEY_CTX_free(p); } }; struct X509Del { void operator()(X509* p) const { X509_free(p); } }; struct BnDel { void operator()(BIGNUM* p) const { BN_free(p); } }; static constexpr const char kSignAlg[] = "RSA_SHA256"; static constexpr const char kSignVer[] = "v1.0"; // Resolve the key file path: obn.conf slicer_key_pem, or // config_dir/slicer_key.pem when empty. static std::string resolve_key_path() { const auto& cfg = obn::config::current().slicer_key_pem; if (!cfg.empty()) return cfg; return obn::config::path_in_dir("slicer_key.pem"); } // Reads a whole file into a string. "" on any failure. `path` is an absolute // or config-relative path already resolved by the caller. static std::string read_pem_file(const std::string& path) { if (path.empty()) return {}; std::FILE* f = std::fopen(path.c_str(), "rb"); if (!f) return {}; std::string out; char buf[4096]; std::size_t n; while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); std::fclose(f); return out; } } // namespace std::string slicer_cert_pem() { const auto& cfg = obn::config::current().slicer_cert_pem; return read_pem_file(cfg.empty() ? obn::config::path_in_dir("slicer_cert.pem") : cfg); } std::string slicer_crl_pem() { const auto& cfg = obn::config::current().slicer_crl_pem; return read_pem_file(cfg.empty() ? obn::config::path_in_dir("slicer_crl.pem") : cfg); } namespace { // Leaf of slicer_cert.pem (first PEM block). nullptr when absent/unparseable. static std::unique_ptr load_slicer_leaf_cert() { const std::string pem = slicer_cert_pem(); if (pem.empty()) return nullptr; BIO* bio = ::BIO_new_mem_buf(pem.data(), static_cast(pem.size())); if (!bio) return nullptr; X509* cert = ::PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); ::BIO_free(bio); return std::unique_ptr(cert); } static std::string leaf_serial_hex_lower(X509* cert) { const ASN1_INTEGER* sn = ::X509_get_serialNumber(cert); if (!sn) return {}; std::unique_ptr bn(::ASN1_INTEGER_to_BN(sn, nullptr)); if (!bn) return {}; char* hex = ::BN_bn2hex(bn.get()); if (!hex) return {}; std::string serial(hex); ::OPENSSL_free(hex); for (char& c : serial) c = static_cast(std::tolower(static_cast(c))); // Wire form uses an even number of hex digits (byte-aligned). if (serial.size() % 2) serial.insert(serial.begin(), '0'); return serial; } static std::string leaf_issuer_rfc2253(X509* cert) { X509_NAME* name = ::X509_get_issuer_name(cert); if (!name) return {}; BIO* bio = ::BIO_new(::BIO_s_mem()); if (!bio) return {}; if (::X509_NAME_print_ex(bio, name, 0, XN_FLAG_RFC2253) < 0) { ::BIO_free(bio); return {}; } char* data = nullptr; const long len = ::BIO_get_mem_data(bio, &data); std::string issuer; if (data && len > 0) issuer.assign(data, static_cast(len)); ::BIO_free(bio); return issuer; } // Parsed once: MQTT cert_id = serial+issuer; HTTP = issuer:serial. struct AppCertIds { std::string mqtt; std::string http; }; static const AppCertIds& app_cert_ids() { static const AppCertIds ids = []() -> AppCertIds { auto cert = load_slicer_leaf_cert(); if (!cert) return {}; const std::string serial = leaf_serial_hex_lower(cert.get()); const std::string issuer = leaf_issuer_rfc2253(cert.get()); if (serial.empty() || issuer.empty()) return {}; AppCertIds out; out.mqtt = serial + issuer; out.http = issuer + ":" + serial; return out; }(); return ids; } } // namespace // cert_id identifies the slicer's registered signing certificate on Bambu's // backend. Derived from the leaf of slicer_cert.pem: // lowercase_hex(serial) + issuer_RFC2253 (no separator). const std::string& slicer_cert_id() { return app_cert_ids().mqtt; } // HTTP x-bbl-app-certification-id: issuer_RFC2253 + ":" + serial.lower(), // from the same leaf parse as slicer_cert_id(). const std::string& app_certification_id() { return app_cert_ids().http; } namespace { static std::unique_ptr load_pkey() { std::string path = resolve_key_path(); if (path.empty()) return nullptr; std::FILE* f = std::fopen(path.c_str(), "r"); if (!f) return nullptr; EVP_PKEY* raw = PEM_read_PrivateKey(f, nullptr, nullptr, nullptr); std::fclose(f); return std::unique_ptr(raw); } EVP_PKEY* slicer_pkey() { static const std::unique_ptr key = load_pkey(); return key.get(); } // RSA-PKCS#1 v1.5 + SHA-256 over `data`, returned as base64. std::string rsa_sha256_sign_b64(EVP_PKEY* pkey, const unsigned char* data, std::size_t len) { if (!pkey) return {}; std::unique_ptr ctx(EVP_MD_CTX_new()); if (!ctx) throw std::runtime_error("signing: EVP_MD_CTX_new failed"); if (EVP_DigestSignInit(ctx.get(), nullptr, EVP_sha256(), nullptr, pkey) != 1) throw std::runtime_error("signing: EVP_DigestSignInit failed"); if (EVP_DigestSignUpdate(ctx.get(), data, len) != 1) throw std::runtime_error("signing: EVP_DigestSignUpdate failed"); std::size_t siglen = 0; if (EVP_DigestSignFinal(ctx.get(), nullptr, &siglen) != 1 || siglen == 0) throw std::runtime_error("signing: EVP_DigestSignFinal (size query) failed"); std::vector sig(siglen); if (EVP_DigestSignFinal(ctx.get(), sig.data(), &siglen) != 1) throw std::runtime_error("signing: EVP_DigestSignFinal failed"); return base64_encode(sig.data(), siglen); } // Raw RSA PKCS#1 v1.5 signature over `data` — the data IS the signed message, // NOT its hash. Unlike rsa_sha256_sign_b64, no digest is applied and no // DigestInfo is prepended: the encoded block is 00 01 FF..FF 00 || data. // Returned as base64. std::string rsa_pkcs1_sign_raw_b64(EVP_PKEY* pkey, const unsigned char* data, std::size_t len) { if (!pkey) return {}; std::unique_ptr ctx(EVP_PKEY_CTX_new(pkey, nullptr)); if (!ctx) throw std::runtime_error("signing: EVP_PKEY_CTX_new failed"); if (EVP_PKEY_sign_init(ctx.get()) != 1) throw std::runtime_error("signing: EVP_PKEY_sign_init failed"); if (EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PADDING) != 1) throw std::runtime_error("signing: set_rsa_padding failed"); std::size_t siglen = 0; if (EVP_PKEY_sign(ctx.get(), nullptr, &siglen, data, len) != 1 || siglen == 0) throw std::runtime_error("signing: EVP_PKEY_sign (size query) failed"); std::vector sig(siglen); if (EVP_PKEY_sign(ctx.get(), sig.data(), &siglen, data, len) != 1) throw std::runtime_error("signing: EVP_PKEY_sign failed"); 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 { 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 {}; ++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 {}; } // 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. // 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). const char* encrypted_field_for(const obn::json::Object& obj) { auto cmd = obj.find("command"); if (cmd == obj.end() || !cmd->second.is_string()) return nullptr; 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; } // Applies device-cert field encryption to the parsed `print` object in place: // adds `_enc` (RSA). On a secured printer (`developer_mode == false`) // the cleartext field is then DROPPED, because secured firmware reads only // *_enc and rejects a message carrying both (gcode_line -> err 84033545 // "mqtt message verify failed"; research/§10.3). When `developer_mode == true` // the cleartext is kept, because Developer Mode firmware ignores *_enc and // reads the cleartext instead. The caller derives `developer_mode` per printer // from push_status print.fun bit 29 (Agent::developer_mode_effective). // Idempotent when `*_enc` already exists. Without a device key (or when RSA // fails) logs ERROR and leaves the cleartext. void encrypt_print_fields(obn::json::Object& obj, EVP_PKEY* device_pub, bool developer_mode) { const char* field = encrypted_field_for(obj); if (!field) return; const std::string enc_key = std::string(field) + "_enc"; auto it = obj.find(field); if (it == obj.end() || !it->second.is_string() || obj.count(enc_key)) return; if (!device_pub) { OBN_ERROR("no device public key; leaving %s cleartext " "(printer will reject if secured)", field); return; } std::string enc_err; std::string enc = rsa_pkcs1v15_encrypt_b64(device_pub, it->second.as_string(), &enc_err); if (enc.empty()) { OBN_ERROR("RSA encrypt of '%s' failed: %s; leaving cleartext", field, enc_err.empty() ? "unknown" : enc_err.c_str()); return; } obj[enc_key] = obn::json::Value(std::move(enc)); if (!developer_mode) obj.erase(it); } // Builds the command 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) { 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 encrypt_print_fields(obj, device_pub, developer_mode); return obn::json::Value(std::move(obj)).dump(); } // Escapes backslash and double-quote for embedding inside a JSON string // literal whose surrounding quotes are managed by the caller. static std::string json_str_escape(const std::string& s) { std::string out; out.reserve(s.size()); for (char c : s) { if (c == '\\' || c == '"') out += '\\'; out += c; } return out; } // Emits {"header":…,"":dump} ONLY — trailing siblings in the input // payload are not carried. See the invariant comment at to_sign (maybe_sign) // below, locked by test_trailing_sibling_reconstruction in tests/signing_test.cpp. // 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) { std::string out; out.reserve(to_sign.size() + sig_b64.size() + 200); out += "{\"header\":{\"cert_id\":\""; out += json_str_escape(slicer_cert_id()); out += "\",\"payload_len\":"; out += std::to_string(to_sign.size()); out += ",\"sign_alg\":\""; out += kSignAlg; out += "\",\"sign_string\":\""; out += json_str_escape(sig_b64); out += "\",\"sign_ver\":\""; out += kSignVer; out += "\"},\"" + root_key + "\":"; out += dump; out += '}'; return out; } } // namespace bool would_sign(const std::string& payload_json) { return !signable_root_key(payload_json).empty() && slicer_pkey() != nullptr; } bool slicer_signing_key_present() { return slicer_pkey() != nullptr; } 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; 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 // SINGLE-ROOT WRAPPER INVARIANT (locked by test_trailing_sibling_reconstruction // in tests/signing_test.cpp): to_sign is exactly {"":} — // family key first, single root, never sorted at the root level; the dump // here is byte-identical to the dump build_envelope emits (it receives the // same string), so payload_len == bytes signed == wire bytes. // A payload with trailing siblings (e.g. {"print":...,"user_id":...}) currently // DROPS the sibling — pinned, not fixed (research/10.04 Signing consensus; // diverges from the #72/farm consensus, which signs envelope-minus-header // including user_id). const std::string to_sign = "{\"" + root_key + "\":" + dump + "}"; const std::string sig_b64 = rsa_sha256_sign_b64( pkey, reinterpret_cast(to_sign.data()), to_sign.size()); return build_envelope(to_sign, sig_b64, root_key, dump); } std::string sign_bytes(const std::string& data) { EVP_PKEY* pkey = slicer_pkey(); if (!pkey) { const auto& d = obn::config::dir(); throw std::runtime_error( "signing: no key loaded; set slicer_key_pem in obn.conf " "or place slicer_key.pem in the config directory (" + (d.empty() ? "" : d) + ")"); } return rsa_sha256_sign_b64( pkey, reinterpret_cast(data.data()), data.size()); } std::string device_security_sign() { EVP_PKEY* pkey = slicer_pkey(); // Degrade gracefully when no slicer key is configured (same as maybe_sign): // return empty so the caller omits the header rather than crashing the // cloud-connect/print path. The header is only enforced on signed writes. if (!pkey) return {}; // The proprietary plugin signs the *current* time in milliseconds (as a // decimal string) with a raw RSA PKCS#1 v1.5 signature — no hash. The // cloud recovers the timestamp from the signature and checks it is recent // (replay protection). const auto ms = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); const std::string ts = std::to_string(ms); return rsa_pkcs1_sign_raw_b64( pkey, reinterpret_cast(ts.data()), ts.size()); } bool add_pop_headers(std::map& headers) { const std::string& cert_id = app_certification_id(); const std::string sec_sign = device_security_sign(); if (cert_id.empty() || sec_sign.empty()) return false; headers["x-bbl-app-certification-id"] = cert_id; headers["x-bbl-device-security-sign"] = sec_sign; return true; } // Blockwise RSA-PKCS#1 v1.5 encryption -> base64. Splits `plaintext` into // <=kMaxChunk-byte pieces so the total ciphertext is a concatenation of // key-sized blocks (matching the stock plugin's url_enc / param_enc form). std::string rsa_pkcs1v15_encrypt_b64(EVP_PKEY* pub, const std::string& plaintext, std::string* err) { if (!pub) { if (err) *err = "null public key"; return {}; } // PKCS#1 v1.5 max plaintext per block = RSA modulus bytes - 11. const int key_bytes = EVP_PKEY_size(pub); // ciphertext block size (e.g. 256) if (key_bytes <= 11) { if (err) *err = "RSA key too small"; return {}; } const std::size_t max_chunk = static_cast(key_bytes - 11); std::unique_ptr ctx(EVP_PKEY_CTX_new(pub, nullptr)); if (!ctx) { if (err) *err = "EVP_PKEY_CTX_new failed"; return {}; } if (EVP_PKEY_encrypt_init(ctx.get()) <= 0 || EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_PADDING) <= 0) { char ebuf[256]; ERR_error_string_n(ERR_peek_last_error(), ebuf, sizeof(ebuf)); if (err) *err = std::string("encrypt init/padding failed: ") + ebuf; return {}; } std::vector out; const auto* p = reinterpret_cast(plaintext.data()); std::size_t remaining = plaintext.size(); std::size_t offset = 0; // A zero-length input still produces one block (the plugin never encrypts // empty fields, but keep the loop robust rather than emit nothing). do { const std::size_t chunk = remaining < max_chunk ? remaining : max_chunk; std::size_t block_len = 0; if (EVP_PKEY_encrypt(ctx.get(), nullptr, &block_len, p + offset, chunk) <= 0) { char ebuf[256]; ERR_error_string_n(ERR_peek_last_error(), ebuf, sizeof(ebuf)); if (err) *err = std::string("EVP_PKEY_encrypt size query failed: ") + ebuf; return {}; } const std::size_t base = out.size(); out.resize(base + block_len); if (EVP_PKEY_encrypt(ctx.get(), out.data() + base, &block_len, p + offset, chunk) <= 0) { char ebuf[256]; ERR_error_string_n(ERR_peek_last_error(), ebuf, sizeof(ebuf)); if (err) *err = std::string("EVP_PKEY_encrypt failed: ") + ebuf; return {}; } out.resize(base + block_len); offset += chunk; remaining -= chunk; } while (remaining > 0); return base64_encode(out.data(), out.size()); } static constexpr char kB64Tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string base64_encode(const unsigned char* data, std::size_t len) { std::string out; out.reserve(((len + 2) / 3) * 4); for (std::size_t i = 0; i < len; i += 3) { std::uint32_t w = static_cast(data[i]) << 16; if (i + 1 < len) w |= static_cast(data[i + 1]) << 8; if (i + 2 < len) w |= static_cast(data[i + 2]); out.push_back(kB64Tbl[(w >> 18) & 63]); out.push_back(kB64Tbl[(w >> 12) & 63]); out.push_back(i + 1 < len ? kB64Tbl[(w >> 6) & 63] : '='); out.push_back(i + 2 < len ? kB64Tbl[w & 63] : '='); } return out; } bool slicer_app_cert_usable() { // app_cert_install needs the app certificate PEM + CRL only (no private // key). Defaults under config_dir count when obn.conf paths are empty. // Soft date/revocation issues still return true (firmware accepts expired // official CRLs); WARN once — Studio calls install_device_cert ~1 Hz. // The cert id is logged once on the first successful check for the same // reason. static bool warned_cert_dates = false; static bool warned_crl_dates = false; static bool warned_revoked = false; static bool logged_cert_id = false; const std::string pem = slicer_cert_pem(); if (pem.empty()) return false; const std::string crl_pem = slicer_crl_pem(); if (crl_pem.empty()) return false; BIO* bio = ::BIO_new_mem_buf(pem.data(), static_cast(pem.size())); if (!bio) return false; X509* cert = ::PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); ::BIO_free(bio); if (!cert) { OBN_ERROR("signing: slicer_cert.pem is not a valid X.509 certificate"); return false; } // notBefore must be in the past; notAfter must be in the future. // X509_cmp_current_time: <0 if asn1_time is before now, >0 if after. const ASN1_TIME* not_before = ::X509_get0_notBefore(cert); const ASN1_TIME* not_after = ::X509_get0_notAfter(cert); const bool not_yet = not_before && ::X509_cmp_current_time(not_before) > 0; const bool expired = !not_after || ::X509_cmp_current_time(not_after) < 0; if ((not_yet || expired) && !warned_cert_dates) { warned_cert_dates = true; OBN_WARN("signing: slicer app certificate is %s " "(still usable for app_cert_install)", expired ? "expired" : "not yet valid"); } BIO* crl_bio = ::BIO_new_mem_buf(crl_pem.data(), static_cast(crl_pem.size())); if (!crl_bio) { ::X509_free(cert); return false; } X509_CRL* crl = ::PEM_read_bio_X509_CRL(crl_bio, nullptr, nullptr, nullptr); ::BIO_free(crl_bio); if (!crl) { ::X509_free(cert); OBN_ERROR("signing: slicer_crl.pem is not a valid X.509 CRL"); return false; } // lastUpdate (thisUpdate) must be in the past; nextUpdate, when present, // must be in the future. Missing nextUpdate is treated as still valid — // some CRLs omit it; request_app_cert_install still needs the PEM. // Official Bambu CRLs are often past nextUpdate; printer still accepts them. const ASN1_TIME* last_update = ::X509_CRL_get0_lastUpdate(crl); const ASN1_TIME* next_update = ::X509_CRL_get0_nextUpdate(crl); const bool crl_not_yet = last_update && ::X509_cmp_current_time(last_update) > 0; const bool crl_expired = next_update && ::X509_cmp_current_time(next_update) < 0; if ((crl_not_yet || crl_expired) && !warned_crl_dates) { warned_crl_dates = true; OBN_WARN("signing: slicer app CRL is %s " "(still usable for app_cert_install; firmware ignores nextUpdate)", crl_expired ? "expired" : "not yet valid"); } // Soft-warn if this leaf appears on the CRL (still allow install). X509_REVOKED* revoked = nullptr; if (::X509_CRL_get0_by_cert(crl, &revoked, cert) == 1 && !warned_revoked) { warned_revoked = true; OBN_WARN("signing: slicer app certificate is revoked on slicer_crl.pem " "(still usable for app_cert_install)"); } ::X509_CRL_free(crl); ::X509_free(cert); if (!logged_cert_id) { logged_cert_id = true; const std::string& id = slicer_cert_id(); if (id.empty()) OBN_WARN("signing: slicer app certificate loaded but cert_id is empty"); else OBN_INFO("signing: slicer app certificate id %s", id.c_str()); } return true; } } // namespace obn::signing