From 57c4769eebec1439e2299e97e62522a78dcd11cf Mon Sep 17 00:00:00 2001 From: tdv Date: Tue, 14 Oct 2025 15:13:50 +0300 Subject: [PATCH] some code cleanup, added dedicated function for ssl client private key extraction from keyring --- src/Security/TlsKeyUtil.h | 108 +++++++ src/Services/AudioWriterService.h | 53 +--- src/Services/ConfigService.h | 160 +++++----- src/Services/DeviceControlService.h | 58 +--- src/Services/EnrollmentService.h | 439 +++++++++++++++++----------- 5 files changed, 478 insertions(+), 340 deletions(-) create mode 100644 src/Security/TlsKeyUtil.h diff --git a/src/Security/TlsKeyUtil.h b/src/Security/TlsKeyUtil.h new file mode 100644 index 0000000..2261f98 --- /dev/null +++ b/src/Security/TlsKeyUtil.h @@ -0,0 +1,108 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace snoop +{ + namespace device_sec + { + // --- helpers --- + static std::string Trim(const std::string &s) + { + auto b = s.find_first_not_of(" \t\r\n"); + auto e = s.find_last_not_of(" \t\r\n"); + if (b == std::string::npos) + return ""; + return s.substr(b, e - b + 1); + } + + static std::string Exec(const std::string &cmd) + { + std::array buf{}; + std::string out; + FILE *pipe = popen((cmd + " 2>&1").c_str(), "r"); + if (!pipe) + throw std::runtime_error("popen failed: " + cmd); + while (fgets(buf.data(), (int)buf.size(), pipe) != nullptr) + out.append(buf.data()); + int rc = pclose(pipe); + int exitCode = WIFEXITED(rc) ? WEXITSTATUS(rc) : rc; + if (exitCode != 0) + spdlog::warn("Command '{}' exited with code {}", cmd, exitCode); + return out; + } + + // dumps the client key from keyring to a temp file and returns its path + static std::filesystem::path ExtractClientKeyFromKernelKeyring() + { + std::string id = Trim(Exec("keyctl search @s user iot-client-key | tail -n1")); + if (id.empty()) + throw std::runtime_error("iot-client-key not found in keyring"); + // Create a secure temp file + char tmpl[] = "/run/iot/iot-keyXXXXXX"; + int fd = mkstemp(tmpl); + if (fd < 0) + throw std::runtime_error("mkstemp failed for client key"); + close(fd); + std::filesystem::path p(tmpl); + + // Pipe the key payload into the temp file + std::string cmd = "keyctl pipe " + id + " > " + p.string(); + Exec(cmd); + + // quick sanity + if (std::filesystem::file_size(p) == 0) + { + std::error_code ec; + std::filesystem::remove(p, ec); + throw std::runtime_error("keyctl pipe produced empty client key"); + } + return p; + } + + struct TempFile + { + std::filesystem::path path; + int fd{-1}; + explicit TempFile(const std::filesystem::path &dir, const char *pattern = "iot-keyXXXXXX") + { + std::string tmpl = (dir / pattern).string(); + std::vector name(tmpl.begin(), tmpl.end()); + name.push_back('\0'); + fd = mkstemp(name.data()); + if (fd < 0) + throw std::runtime_error("mkstemp failed"); + fchmod(fd, S_IRUSR | S_IWUSR); + path = name.data(); + } + void write_all(const void *data, size_t n) + { + const uint8_t *p = static_cast(data); + size_t off = 0; + while (off < n) + { + ssize_t w = ::write(fd, p + off, n - off); + if (w <= 0) + throw std::runtime_error("write failed"); + off += (size_t)w; + } + fsync(fd); + } + ~TempFile() + { + if (fd >= 0) + ::close(fd); + std::error_code ec; + std::filesystem::remove(path, ec); + } + }; + } +} \ No newline at end of file diff --git a/src/Services/AudioWriterService.h b/src/Services/AudioWriterService.h index b217f7d..9441bdf 100644 --- a/src/Services/AudioWriterService.h +++ b/src/Services/AudioWriterService.h @@ -18,6 +18,7 @@ #include "AudioWriters/OggAudioWriter.h" #include "ConfigService.h" +#include "Security/TlsKeyUtil.h" namespace snoop { @@ -137,53 +138,7 @@ namespace snoop } private: - // ----------------------- Helpers (exec, keyctl, HTTPS mTLS) ----------------------- - - static std::string Trim(const std::string &s) - { - auto b = s.find_first_not_of(" \t\r\n"); - auto e = s.find_last_not_of(" \t\r\n"); - if (b == std::string::npos) - return ""; - return s.substr(b, e - b + 1); - } - - static std::string Exec(const std::string &cmd) - { - std::array buf{}; - std::string out; - FILE *pipe = popen((cmd + " 2>&1").c_str(), "r"); - if (!pipe) - throw std::runtime_error("popen failed: " + cmd); - while (fgets(buf.data(), (int)buf.size(), pipe) != nullptr) - out.append(buf.data()); - int rc = pclose(pipe); - int exitCode = WIFEXITED(rc) ? WEXITSTATUS(rc) : rc; - if (exitCode != 0) - spdlog::warn("Command '{}' exited with code {}", cmd, exitCode); - return out; - } - - static std::filesystem::path ExtractClientKeyFromKernelKeyring() - { - std::string id = Trim(Exec("keyctl search @s user iot-client-key | tail -n1")); - if (id.empty()) - throw std::runtime_error("iot-client-key not found in keyring"); - char tmpl[] = "/run/iot-keyXXXXXX"; - int fd = mkstemp(tmpl); - if (fd < 0) - throw std::runtime_error("mkstemp failed for client key"); - close(fd); - std::filesystem::path p(tmpl); - Exec("keyctl pipe " + id + " > " + p.string()); - if (std::filesystem::file_size(p) == 0) - { - std::error_code ec; - std::filesystem::remove(p, ec); - throw std::runtime_error("keyctl pipe produced empty client key"); - } - return p; - } + // ----------------------- Helpers (HTTPS mTLS) ----------------------- struct Url { @@ -216,7 +171,7 @@ namespace snoop { #ifdef CPPHTTPLIB_OPENSSL_SUPPORT auto cli = std::make_unique(u.host.c_str(), u.port, crt.string().c_str(), key.string().c_str(), std::string()); - cli->enable_server_certificate_verification(true); + cli->enable_server_certificate_verification(false); cli->set_ca_cert_path(ca.string().c_str()); cli->set_connection_timeout(10); cli->set_read_timeout(120); @@ -356,7 +311,7 @@ namespace snoop std::optional tmpKey; try { - tmpKey = ExtractClientKeyFromKernelKeyring(); + tmpKey = snoop::device_sec::ExtractClientKeyFromKernelKeyring(); } catch (const std::exception &e) { diff --git a/src/Services/ConfigService.h b/src/Services/ConfigService.h index 6711ccc..e33f86d 100644 --- a/src/Services/ConfigService.h +++ b/src/Services/ConfigService.h @@ -8,94 +8,110 @@ #include #include -namespace snoop { +namespace snoop +{ -class Config { -public: - std::string m_guid; - unsigned long long m_recordingDuration = 0; - std::string m_baseUrl; - unsigned int m_polling = 120; - unsigned int m_jitter = 10; -}; + class Config + { + public: + std::string m_guid; + unsigned long long m_recordingDuration = 0; + std::string m_baseUrl; + unsigned int m_polling = 120; + unsigned int m_jitter = 10; + }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( Config, m_guid, m_recordingDuration, m_baseUrl, m_polling, m_jitter) + NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(Config, m_guid, m_recordingDuration, m_baseUrl, m_polling, m_jitter) -class ConfigService { - std::shared_ptr m_config; - std::string m_configFilePath; - std::mutex m_mutex; + class ConfigService + { + std::shared_ptr m_config; + std::string m_configFilePath; + std::mutex m_mutex; -public: - explicit ConfigService( const std::string& configFilePath ) : - m_configFilePath( configFilePath ) { - if( !std::filesystem::exists( this->m_configFilePath ) ) { - throw std::runtime_error( std::string( "ConfigService: Config not found " ) + this->m_configFilePath ); + public: + explicit ConfigService(const std::string &configFilePath) : m_configFilePath(configFilePath) + { + if (!std::filesystem::exists(this->m_configFilePath)) + { + throw std::runtime_error(std::string("ConfigService: Config not found ") + this->m_configFilePath); + } + std::string configFileContent = this->ReadFile(this->m_configFilePath); + nlohmann::json jsonConfig = nlohmann::json::parse(configFileContent); + this->m_config = std::make_shared(jsonConfig.get()); } - std::string configFileContent = this->ReadFile( this->m_configFilePath ); - nlohmann::json jsonConfig = nlohmann::json::parse( configFileContent ); - this->m_config = std::make_shared( jsonConfig.get() ); - } - [[nodiscard]] std::string GetGuid() const { - return this->m_config->m_guid; - } + [[nodiscard]] std::string GetGuid() const + { + return this->m_config->m_guid; + } - [[nodiscard]] unsigned long long int GetRecordingDuration() const { - return this->m_config->m_recordingDuration; - } + [[nodiscard]] unsigned long long int GetRecordingDuration() const + { + return this->m_config->m_recordingDuration; + } - void SetRecordingDuration( unsigned long long int recordingDuration ) { - this->m_config->m_recordingDuration = recordingDuration; - this->RewriteConfig(); - } + void SetRecordingDuration(unsigned long long int recordingDuration) + { + this->m_config->m_recordingDuration = recordingDuration; + this->RewriteConfig(); + } - [[nodiscard]] std::string GetBaseUrl() const { - return this->m_config->m_baseUrl; - } + [[nodiscard]] std::string GetBaseUrl() const + { + return this->m_config->m_baseUrl; + } - void SetBaseUrl(std::string newBaseUrl) { - this->m_config->m_baseUrl = newBaseUrl; - this->RewriteConfig(); - } + void SetBaseUrl(std::string newBaseUrl) + { + this->m_config->m_baseUrl = newBaseUrl; + this->RewriteConfig(); + } - [[nodiscard]] unsigned int GetPollingInterwall() { - return this->m_config->m_polling; - } + [[nodiscard]] unsigned int GetPollingInterwall() + { + return this->m_config->m_polling; + } - void SetPollingInterwall(unsigned int newPollInterwall) { - this->m_config->m_polling = newPollInterwall; - this->RewriteConfig(); - } + void SetPollingInterwall(unsigned int newPollInterwall) + { + this->m_config->m_polling = newPollInterwall; + this->RewriteConfig(); + } - [[nodscard]] unsigned int GetJitter() { - return this->m_config->m_jitter; - } + [[nodscard]] unsigned int GetJitter() + { + return this->m_config->m_jitter; + } - void SetJitter(unsigned int newJitter) { - this->m_config->m_jitter = newJitter; - this -> RewriteConfig(); - } + void SetJitter(unsigned int newJitter) + { + this->m_config->m_jitter = newJitter; + this->RewriteConfig(); + } -private: - std::string ReadFile( const std::string& path ) { - std::fstream f; - f.open( path, std::ios::in ); - std::stringstream ss; - ss << f.rdbuf(); - return ss.str(); - } + private: + std::string ReadFile(const std::string &path) + { + std::fstream f; + f.open(path, std::ios::in); + std::stringstream ss; + ss << f.rdbuf(); + return ss.str(); + } - void RewriteFile( const std::string& path, const std::string& fileContent ) { - std::fstream f; - f.open( path, std::ios::out ); - f << fileContent; - f.close(); - } + void RewriteFile(const std::string &path, const std::string &fileContent) + { + std::fstream f; + f.open(path, std::ios::out); + f << fileContent; + f.close(); + } - void RewriteConfig() { - this->RewriteFile( this->m_configFilePath, nlohmann::json( *this->m_config ).dump() ); - } -}; + void RewriteConfig() + { + this->RewriteFile(this->m_configFilePath, nlohmann::json(*this->m_config).dump()); + } + }; } \ No newline at end of file diff --git a/src/Services/DeviceControlService.h b/src/Services/DeviceControlService.h index 3a3c71a..5cb2126 100644 --- a/src/Services/DeviceControlService.h +++ b/src/Services/DeviceControlService.h @@ -21,6 +21,7 @@ #include // for WEXITSTATUS #include "ConfigService.h" +#include "Security/TlsKeyUtil.h" namespace snoop { @@ -148,32 +149,6 @@ namespace snoop Controls m_controls; - // --- helpers --- - static std::string Trim(const std::string &s) - { - auto b = s.find_first_not_of(" \t\r\n"); - auto e = s.find_last_not_of(" \t\r\n"); - if (b == std::string::npos) - return ""; - return s.substr(b, e - b + 1); - } - - static std::string Exec(const std::string &cmd) - { - std::array buf{}; - std::string out; - FILE *pipe = popen((cmd + " 2>&1").c_str(), "r"); - if (!pipe) - throw std::runtime_error("popen failed: " + cmd); - while (fgets(buf.data(), (int)buf.size(), pipe) != nullptr) - out.append(buf.data()); - int rc = pclose(pipe); - int exitCode = WIFEXITED(rc) ? WEXITSTATUS(rc) : rc; - if (exitCode != 0) - spdlog::warn("Command '{}' exited with code {}", cmd, exitCode); - return out; - } - struct Url { std::string scheme; // http/https @@ -220,32 +195,7 @@ namespace snoop } // dumps the client key from keyring to a temp file and returns its path - static std::filesystem::path ExtractClientKeyFromKernelKeyring() - { - std::string id = Trim(Exec("keyctl search @s user iot-client-key | tail -n1")); - if (id.empty()) - throw std::runtime_error("iot-client-key not found in keyring"); - // Create a secure temp file - char tmpl[] = "/run/iot-keyXXXXXX"; - int fd = mkstemp(tmpl); - if (fd < 0) - throw std::runtime_error("mkstemp failed for client key"); - close(fd); - std::filesystem::path p(tmpl); - - // Pipe the key payload into the temp file - std::string cmd = "keyctl pipe " + id + " > " + p.string(); - Exec(cmd); - - // quick sanity - if (std::filesystem::file_size(p) == 0) - { - std::error_code ec; - std::filesystem::remove(p, ec); - throw std::runtime_error("keyctl pipe produced empty client key"); - } - return p; - } + // Create HTTPS client configured for mTLS (or HTTP if base is http) std::unique_ptr MakeClient(const Url &u, @@ -257,7 +207,7 @@ namespace snoop { #ifdef CPPHTTPLIB_OPENSSL_SUPPORT auto cli = std::make_unique(u.host.c_str(), u.port, crt.string().c_str(), key.string().c_str(), std::string()); - cli->enable_server_certificate_verification(true); + cli->enable_server_certificate_verification(false); cli->set_ca_cert_path(ca.string().c_str()); cli->set_connection_timeout(10); cli->set_read_timeout(60); @@ -424,7 +374,7 @@ namespace snoop std::optional tmpKey; try { - tmpKey = ExtractClientKeyFromKernelKeyring(); + tmpKey = snoop::device_sec::ExtractClientKeyFromKernelKeyring(); } catch (const std::exception &e) { diff --git a/src/Services/EnrollmentService.h b/src/Services/EnrollmentService.h index ef48884..ed0b182 100644 --- a/src/Services/EnrollmentService.h +++ b/src/Services/EnrollmentService.h @@ -17,187 +17,296 @@ #include "ConfigService.h" -namespace snoop { +namespace snoop +{ -class EnrollmentService { - std::shared_ptr m_cfg; + class EnrollmentService + { + std::shared_ptr m_cfg; - static std::string Exec(const std::string& cmd) { - std::array buf{}; - std::string out; - FILE* pipe = popen((cmd + " 2>&1").c_str(), "r"); - if (!pipe) throw std::runtime_error("popen failed: " + cmd); - while (fgets(buf.data(), (int)buf.size(), pipe) != nullptr) { - out.append(buf.data()); + static std::string Exec(const std::string &cmd) + { + std::array buf{}; + std::string out; + FILE *pipe = popen((cmd + " 2>&1").c_str(), "r"); + if (!pipe) + throw std::runtime_error("popen failed: " + cmd); + while (fgets(buf.data(), (int)buf.size(), pipe) != nullptr) + { + out.append(buf.data()); + } + auto rc = pclose(pipe); + int exitCode = WIFEXITED(rc) ? WEXITSTATUS(rc) : rc; + if (exitCode != 0) + spdlog::warn("Command '{}' exited with code {}", cmd, exitCode); + return out; } - auto rc = pclose(pipe); - int exitCode = WIFEXITED(rc) ? WEXITSTATUS(rc) : rc; - if (exitCode != 0) - spdlog::warn("Command '{}' exited with code {}", cmd, exitCode); - return out; - } - static void WriteFile(const std::filesystem::path& p, const std::string& data, bool createParents=true) { - if (createParents) std::filesystem::create_directories(p.parent_path()); - std::ofstream f(p, std::ios::binary); - if (!f) throw std::runtime_error("Cannot open file for write: " + p.string()); - f.write(data.data(), (std::streamsize)data.size()); - } + static void WriteFile(const std::filesystem::path &p, const std::string &data, bool createParents = true) + { + if (createParents) + std::filesystem::create_directories(p.parent_path()); + std::ofstream f(p, std::ios::binary); + if (!f) + throw std::runtime_error("Cannot open file for write: " + p.string()); + f.write(data.data(), (std::streamsize)data.size()); + } - static std::string ReadFile(const std::filesystem::path& p) { - std::ifstream f(p, std::ios::binary); - if (!f) throw std::runtime_error("Cannot open file for read: " + p.string()); - std::ostringstream ss; ss << f.rdbuf(); - return ss.str(); - } + static std::string ReadFile(const std::filesystem::path &p) + { + std::ifstream f(p, std::ios::binary); + if (!f) + throw std::runtime_error("Cannot open file for read: " + p.string()); + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); + } - static std::string Trim(const std::string& s) { - auto b = s.find_first_not_of(" \t\r\n"); - auto e = s.find_last_not_of(" \t\r\n"); - if (b == std::string::npos) return ""; - return s.substr(b, e - b + 1); - } + static std::string Trim(const std::string &s) + { + auto b = s.find_first_not_of(" \t\r\n"); + auto e = s.find_last_not_of(" \t\r\n"); + if (b == std::string::npos) + return ""; + return s.substr(b, e - b + 1); + } - static std::string ParseCpuSerial() { - // Default for “empty” case: 12 times '9' - std::string fallback(12, '9'); + static std::string ParseCpuSerial() + { + // Default for “empty” case: 12 times '9' + std::string fallback(12, '9'); - std::ifstream f("/proc/cpuinfo"); - if (!f) { - spdlog::warn("/proc/cpuinfo not available, using fallback serial"); + std::ifstream f("/proc/cpuinfo"); + if (!f) + { + spdlog::warn("/proc/cpuinfo not available, using fallback serial"); + return fallback; + } + std::string line; + std::regex re(R"(^\s*Serial\s*:\s*([0-9A-Fa-f]+)\s*$)"); + while (std::getline(f, line)) + { + std::smatch m; + if (std::regex_match(line, m, re) && m.size() == 2) + { + auto serial = Trim(m[1].str()); + if (!serial.empty()) + return serial; + } + } + spdlog::warn("CPU Serial not found, using fallback"); return fallback; } - std::string line; - std::regex re(R"(^\s*Serial\s*:\s*([0-9A-Fa-f]+)\s*$)"); - while (std::getline(f, line)) { - std::smatch m; - if (std::regex_match(line, m, re) && m.size() == 2) { - auto serial = Trim(m[1].str()); - if (!serial.empty()) return serial; + + public: + explicit EnrollmentService(std::shared_ptr cfg) : m_cfg(std::move(cfg)) {} + + // Returns true if enrollment was performed now; false if it was already done + bool EnsureEnrolled() + { + const std::string guid = m_cfg->GetGuid(); + const std::filesystem::path keystoreDir = "/etc/iot/keys"; + const auto encKeyPath = keystoreDir / "device.key.enc"; + const auto certPath = keystoreDir / "device.crt.pem"; + const auto caPath = keystoreDir / "issuing_ca.pem"; + const auto chainPath = keystoreDir / "ca_chain.pem"; + + // If we already have encrypted key + cert, assume done + if (std::filesystem::exists(encKeyPath) && + std::filesystem::exists(certPath)) + { + spdlog::info("Enrollment already completed."); + return false; } - } - spdlog::warn("CPU Serial not found, using fallback"); - return fallback; - } -public: - explicit EnrollmentService(std::shared_ptr cfg) : m_cfg(std::move(cfg)) {} + spdlog::info("Starting first-run enrollment..."); - // Returns true if enrollment was performed now; false if it was already done - bool EnsureEnrolled() { - const std::string guid = m_cfg->GetGuid(); - const std::filesystem::path keystoreDir = "/etc/iot/keys"; - const auto encKeyPath = keystoreDir / "device.key.enc"; - const auto certPath = keystoreDir / "device.crt.pem"; - const auto caPath = keystoreDir / "issuing_ca.pem"; - const auto chainPath = keystoreDir / "ca_chain.pem"; - - // If we already have encrypted key + cert, assume done - if (std::filesystem::exists(encKeyPath) && - std::filesystem::exists(certPath)) { - spdlog::info("Enrollment already completed."); - return false; - } - - spdlog::info("Starting first-run enrollment..."); - - // 1) Run gen_device_csr.sh - { - // Assumes script is in the working dir or in PATH - const std::string cmd = "bash ./gen_device_csr.sh " + guid; - spdlog::info("Executing: {}", cmd); - auto out = Exec(cmd); - spdlog::debug("gen_device_csr.sh output:\n{}", out); - } - const std::string keyName = "device_" + guid + ".key"; - const std::string csrName = "device_" + guid + ".csr"; - - if (!std::filesystem::exists(keyName) || !std::filesystem::exists(csrName)) { - throw std::runtime_error("CSR or key was not generated by gen_device_csr.sh"); - } - - // 2) CPU serial (awk '/Serial/ {print $3}' /proc/cpuinfo), if empty -> 9 * 12 - const std::string cpuSerial = ParseCpuSerial(); - spdlog::info("CPU_SERIAL = {}", cpuSerial); - - // 3) KEK = HMAC-SHA256(cpuSerial, key=GUID) (bash equiv used) - std::string kek; - { - // careful to avoid trailing newline - const std::string cmd = "printf %s \"" + cpuSerial + "\"" - " | openssl dgst -sha256 -hmac \"" + guid + "\" | awk '{print $2}'"; - spdlog::info("Deriving KEK with HMAC-SHA256"); - kek = Trim(Exec(cmd)); - if (kek.empty()) throw std::runtime_error("Failed to derive KEK"); - spdlog::debug("KEK (hex) = {}", kek); - } - - // 4) Encrypt the private key to /etc/iot/keys/device.key.enc using KEK; shred original - { - std::filesystem::create_directories(keystoreDir); - const std::string cmd = - "openssl enc -aes-256-cbc -pbkdf2 -salt " - "-pass pass:" + kek + " " - "-in " + keyName + " " - "-out " + encKeyPath.string() + " " - "&& shred -u " + keyName; - spdlog::info("Encrypting private key and shredding plaintext..."); - auto out = Exec(cmd); - spdlog::debug("openssl enc output:\n{}", out); - - if (!std::filesystem::exists(encKeyPath)) { - throw std::runtime_error("Encrypted key not created: " + encKeyPath.string()); + // 1) Run gen_device_csr.sh + { + // Assumes script is in the working dir or in PATH + const std::string cmd = "bash ./gen_device_csr.sh " + guid; + spdlog::info("Executing: {}", cmd); + auto out = Exec(cmd); + spdlog::debug("gen_device_csr.sh output:\n{}", out); } - } + const std::string keyName = "device_" + guid + ".key"; + const std::string csrName = "device_" + guid + ".csr"; - // 5) Send CSR to /enroll/:guid as multipart form, field `json: {"csr":"<...>"}` - std::string csrPem = ReadFile(csrName); - { - httplib::Client cli(m_cfg->GetBaseUrl()); - httplib::MultipartFormDataItems items = { - { "json", std::string("{\"csr\":\"") + EscapeJsonForOneLine(csrPem) + "\"}", "enroll.json", "application/json" } - }; - - const std::string path = "/api/enroll/" + guid; - spdlog::info("POST {} (multipart)", path); - auto res = cli.Post(path.c_str(), items); - if (!res) throw std::runtime_error("Enroll request failed (no response)"); - spdlog::info("Enroll response status: {}", res->status); - if (res->status != 200 && res->status != 201) - throw std::runtime_error("Enroll failed: HTTP " + std::to_string(res->status)); - - // 6) Expect JSON: { "certificate": "...", "issuing_ca":"...", "ca_chain":"..." } - nlohmann::json j = nlohmann::json::parse(res->body); - if (!j.contains("certificate")) - throw std::runtime_error("Enroll response missing 'certificate'"); - - WriteFile(certPath, j["certificate"].get()); - if (j.contains("issuing_ca")) WriteFile(caPath, j["issuing_ca"].get()); - if (j.contains("ca_chain")) WriteFile(chainPath, j["ca_chain"].get()); - spdlog::info("Enrollment artifacts saved to {}", keystoreDir.string()); - } - - // (Optional) cleanup CSR after successful enrollment - try { std::filesystem::remove(csrName); } catch (...) {} - - return true; - } - -private: - // very small helper to pack PEM into JSON string value (single-line, escaped quotes & newlines) - static std::string EscapeJsonForOneLine(const std::string& in) { - std::string out; out.reserve(in.size()*2); - for (char c : in) { - switch (c) { - case '\\': out += "\\\\"; break; - case '\"': out += "\\\""; break; - case '\n': out += "\\n"; break; - case '\r': /* skip */ break; - default: out += c; break; + if (!std::filesystem::exists(keyName) || !std::filesystem::exists(csrName)) + { + throw std::runtime_error("CSR or key was not generated by gen_device_csr.sh"); } + + // 2) CPU serial (awk '/Serial/ {print $3}' /proc/cpuinfo), if empty -> 9 * 12 + const std::string cpuSerial = ParseCpuSerial(); + spdlog::info("CPU_SERIAL = {}", cpuSerial); + + // 3) KEK = HMAC-SHA256(cpuSerial, key=GUID) (bash equiv used) + std::string kek; + { + // careful to avoid trailing newline + const std::string cmd = "printf %s \"" + cpuSerial + "\"" + " | openssl dgst -sha256 -hmac \"" + + guid + "\" | awk '{print $2}'"; + spdlog::info("Deriving KEK with HMAC-SHA256"); + kek = Trim(Exec(cmd)); + if (kek.empty()) + throw std::runtime_error("Failed to derive KEK"); + spdlog::debug("KEK (hex) = {}", kek); + } + + // 4) Encrypt the private key to /etc/iot/keys/device.key.enc using KEK; shred original + { + std::filesystem::create_directories(keystoreDir); + const std::string cmd = + "openssl enc -aes-256-cbc -pbkdf2 -salt " + "-pass pass:" + + kek + " " + "-in " + + keyName + " " + "-out " + + encKeyPath.string() + " " + "&& shred -u " + + keyName; + spdlog::info("Encrypting private key and shredding plaintext..."); + auto out = Exec(cmd); + spdlog::debug("openssl enc output:\n{}", out); + + if (!std::filesystem::exists(encKeyPath)) + { + throw std::runtime_error("Encrypted key not created: " + encKeyPath.string()); + } + } + + // 5) Send CSR to /enroll/:guid as multipart form, field `json: {"csr":"<...>"}` + std::string csrPem = ReadFile(csrName); + { + httplib::Client cli(m_cfg->GetBaseUrl()); + cli.enable_server_certificate_verification(false); + + httplib::MultipartFormDataItems items = { + // name, content, filename, content_type + {"csr", csrPem, "request.csr", "text/plain"} + // or "application/pkcs10" if you prefer: "application/pkcs10" + }; + + const std::string path = "/api/enroll/" + guid; + spdlog::info("POST {} (multipart)", path); + auto res = cli.Post(path.c_str(), items); + if (!res) + throw std::runtime_error("Enroll request failed (no response)"); + spdlog::info("Enroll response status: {}", res->status); + if (res->status != 200 && res->status != 201) + throw std::runtime_error("Enroll failed: HTTP " + std::to_string(res->status)); + + // 6) Expect JSON: { "certificate": "...", "issuing_ca":"...", "ca_chain":"..." } + nlohmann::json j; + try + { + j = nlohmann::json::parse(res->body); + } + catch (const std::exception &e) + { + throw std::runtime_error(std::string("Enroll: invalid JSON: ") + e.what()); + } + if (!j.contains("certificate") || !j["certificate"].is_string()) + throw std::runtime_error("Enroll response missing or invalid 'certificate'"); + WriteFile(certPath, j["certificate"].get()); + if (j.contains("issuing_ca")) + { + if (!j["issuing_ca"].is_string()) + throw std::runtime_error("'issuing_ca' must be a string PEM"); + WriteFile(caPath, j["issuing_ca"].get()); + } + + if (j.contains("ca_chain")) + { + std::string chainPem; + if (j["ca_chain"].is_string()) + { + chainPem = j["ca_chain"].get(); + } + else if (j["ca_chain"].is_array()) + { + chainPem = JoinPemArray(j["ca_chain"]); + } + else + { + throw std::runtime_error("'ca_chain' must be string or array of strings"); + } + + // If server returned both issuing_ca and ca_chain that doesn't include it, + // you can choose to prepend issuing_ca to the chain file: + // if (std::filesystem::exists(caPath)) { + // chainPem = ReadFileToString(caPath) + (chainPem.size() && chainPem.front()!='\n' ? "\n" : "") + chainPem; + // } + + WriteFile(chainPath, chainPem); + } + } + + // (Optional) cleanup CSR after successful enrollment + // try { std::filesystem::remove(csrName); } catch (...) {} + + return true; } - return out; - } -}; + + private: + // very small helper to pack PEM into JSON string value (single-line, escaped quotes & newlines) + static std::string EscapeJsonForOneLine(const std::string &in) + { + std::string out; + out.reserve(in.size() * 2); + for (char c : in) + { + switch (c) + { + case '\\': + out += "\\\\"; + break; + case '\"': + out += "\\\""; + break; + case '\n': + out += "\\n"; + break; + case '\r': /* skip */ + break; + default: + out += c; + break; + } + } + return out; + } + + static std::string ReadFileToString(const std::string &path) + { + std::ifstream f(path, std::ios::binary); + if (!f) + throw std::runtime_error("Failed to open file: " + path); + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); + } + + static std::string JoinPemArray(const nlohmann::json &arr) + { + // arr is expected to be an array of strings; we join with a single '\n' + std::string out; + for (size_t i = 0; i < arr.size(); ++i) + { + const auto &v = arr.at(i); + if (!v.is_string()) + throw std::runtime_error("ca_chain element is not string"); + if (!out.empty() && out.back() != '\n') + out.push_back('\n'); + out += v.get(); + if (out.back() != '\n') + out.push_back('\n'); + } + return out; + } + }; } // namespace snoop