some code cleanup, added dedicated function for ssl client private key extraction from keyring

This commit is contained in:
tdv
2025-10-14 15:13:50 +03:00
parent 9055a55ad3
commit 57c4769eeb
5 changed files with 478 additions and 340 deletions

108
src/Security/TlsKeyUtil.h Normal file
View File

@@ -0,0 +1,108 @@
#pragma once
#include <filesystem>
#include <string>
#include <array>
#include <vector>
#include <stdexcept>
#include <cstdio>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <spdlog/spdlog.h>
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<char, 4096> 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<char> 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<const uint8_t *>(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);
}
};
}
}

View File

@@ -18,6 +18,7 @@
#include "AudioWriters/OggAudioWriter.h" #include "AudioWriters/OggAudioWriter.h"
#include "ConfigService.h" #include "ConfigService.h"
#include "Security/TlsKeyUtil.h"
namespace snoop namespace snoop
{ {
@@ -137,53 +138,7 @@ namespace snoop
} }
private: private:
// ----------------------- Helpers (exec, keyctl, HTTPS mTLS) ----------------------- // ----------------------- Helpers (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<char, 4096> 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;
}
struct Url struct Url
{ {
@@ -216,7 +171,7 @@ namespace snoop
{ {
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT #ifdef CPPHTTPLIB_OPENSSL_SUPPORT
auto cli = std::make_unique<httplib::SSLClient>(u.host.c_str(), u.port, crt.string().c_str(), key.string().c_str(), std::string()); auto cli = std::make_unique<httplib::SSLClient>(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_ca_cert_path(ca.string().c_str());
cli->set_connection_timeout(10); cli->set_connection_timeout(10);
cli->set_read_timeout(120); cli->set_read_timeout(120);
@@ -356,7 +311,7 @@ namespace snoop
std::optional<std::filesystem::path> tmpKey; std::optional<std::filesystem::path> tmpKey;
try try
{ {
tmpKey = ExtractClientKeyFromKernelKeyring(); tmpKey = snoop::device_sec::ExtractClientKeyFromKernelKeyring();
} }
catch (const std::exception &e) catch (const std::exception &e)
{ {

View File

@@ -8,94 +8,110 @@
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
namespace snoop { namespace snoop
{
class Config { class Config
public: {
std::string m_guid; public:
unsigned long long m_recordingDuration = 0; std::string m_guid;
std::string m_baseUrl; unsigned long long m_recordingDuration = 0;
unsigned int m_polling = 120; std::string m_baseUrl;
unsigned int m_jitter = 10; 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 { class ConfigService
std::shared_ptr<Config> m_config; {
std::string m_configFilePath; std::shared_ptr<Config> m_config;
std::mutex m_mutex; std::string m_configFilePath;
std::mutex m_mutex;
public: public:
explicit ConfigService( const std::string& configFilePath ) : explicit ConfigService(const std::string &configFilePath) : m_configFilePath(configFilePath)
m_configFilePath( configFilePath ) { {
if( !std::filesystem::exists( this->m_configFilePath ) ) { if (!std::filesystem::exists(this->m_configFilePath))
throw std::runtime_error( std::string( "ConfigService: Config not found " ) + 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<Config>(jsonConfig.get<Config>());
} }
std::string configFileContent = this->ReadFile( this->m_configFilePath );
nlohmann::json jsonConfig = nlohmann::json::parse( configFileContent );
this->m_config = std::make_shared<Config>( jsonConfig.get<Config>() );
}
[[nodiscard]] std::string GetGuid() const { [[nodiscard]] std::string GetGuid() const
return this->m_config->m_guid; {
} return this->m_config->m_guid;
}
[[nodiscard]] unsigned long long int GetRecordingDuration() const { [[nodiscard]] unsigned long long int GetRecordingDuration() const
return this->m_config->m_recordingDuration; {
} return this->m_config->m_recordingDuration;
}
void SetRecordingDuration( unsigned long long int recordingDuration ) { void SetRecordingDuration(unsigned long long int recordingDuration)
this->m_config->m_recordingDuration = recordingDuration; {
this->RewriteConfig(); this->m_config->m_recordingDuration = recordingDuration;
} this->RewriteConfig();
}
[[nodiscard]] std::string GetBaseUrl() const { [[nodiscard]] std::string GetBaseUrl() const
return this->m_config->m_baseUrl; {
} return this->m_config->m_baseUrl;
}
void SetBaseUrl(std::string newBaseUrl) { void SetBaseUrl(std::string newBaseUrl)
this->m_config->m_baseUrl = newBaseUrl; {
this->RewriteConfig(); this->m_config->m_baseUrl = newBaseUrl;
} this->RewriteConfig();
}
[[nodiscard]] unsigned int GetPollingInterwall() { [[nodiscard]] unsigned int GetPollingInterwall()
return this->m_config->m_polling; {
} return this->m_config->m_polling;
}
void SetPollingInterwall(unsigned int newPollInterwall) { void SetPollingInterwall(unsigned int newPollInterwall)
this->m_config->m_polling = newPollInterwall; {
this->RewriteConfig(); this->m_config->m_polling = newPollInterwall;
} this->RewriteConfig();
}
[[nodscard]] unsigned int GetJitter() { [[nodscard]] unsigned int GetJitter()
return this->m_config->m_jitter; {
} return this->m_config->m_jitter;
}
void SetJitter(unsigned int newJitter) { void SetJitter(unsigned int newJitter)
this->m_config->m_jitter = newJitter; {
this -> RewriteConfig(); this->m_config->m_jitter = newJitter;
} this->RewriteConfig();
}
private: private:
std::string ReadFile( const std::string& path ) { std::string ReadFile(const std::string &path)
std::fstream f; {
f.open( path, std::ios::in ); std::fstream f;
std::stringstream ss; f.open(path, std::ios::in);
ss << f.rdbuf(); std::stringstream ss;
return ss.str(); ss << f.rdbuf();
} return ss.str();
}
void RewriteFile( const std::string& path, const std::string& fileContent ) { void RewriteFile(const std::string &path, const std::string &fileContent)
std::fstream f; {
f.open( path, std::ios::out ); std::fstream f;
f << fileContent; f.open(path, std::ios::out);
f.close(); f << fileContent;
} f.close();
}
void RewriteConfig() { void RewriteConfig()
this->RewriteFile( this->m_configFilePath, nlohmann::json( *this->m_config ).dump() ); {
} this->RewriteFile(this->m_configFilePath, nlohmann::json(*this->m_config).dump());
}; }
};
} }

View File

@@ -21,6 +21,7 @@
#include <sys/wait.h> // for WEXITSTATUS #include <sys/wait.h> // for WEXITSTATUS
#include "ConfigService.h" #include "ConfigService.h"
#include "Security/TlsKeyUtil.h"
namespace snoop namespace snoop
{ {
@@ -148,32 +149,6 @@ namespace snoop
Controls m_controls; 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<char, 4096> 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 struct Url
{ {
std::string scheme; // http/https 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 // 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) // Create HTTPS client configured for mTLS (or HTTP if base is http)
std::unique_ptr<httplib::SSLClient> MakeClient(const Url &u, std::unique_ptr<httplib::SSLClient> MakeClient(const Url &u,
@@ -257,7 +207,7 @@ namespace snoop
{ {
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT #ifdef CPPHTTPLIB_OPENSSL_SUPPORT
auto cli = std::make_unique<httplib::SSLClient>(u.host.c_str(), u.port, crt.string().c_str(), key.string().c_str(), std::string()); auto cli = std::make_unique<httplib::SSLClient>(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_ca_cert_path(ca.string().c_str());
cli->set_connection_timeout(10); cli->set_connection_timeout(10);
cli->set_read_timeout(60); cli->set_read_timeout(60);
@@ -424,7 +374,7 @@ namespace snoop
std::optional<std::filesystem::path> tmpKey; std::optional<std::filesystem::path> tmpKey;
try try
{ {
tmpKey = ExtractClientKeyFromKernelKeyring(); tmpKey = snoop::device_sec::ExtractClientKeyFromKernelKeyring();
} }
catch (const std::exception &e) catch (const std::exception &e)
{ {

View File

@@ -17,187 +17,296 @@
#include "ConfigService.h" #include "ConfigService.h"
namespace snoop { namespace snoop
{
class EnrollmentService { class EnrollmentService
std::shared_ptr<ConfigService> m_cfg; {
std::shared_ptr<ConfigService> m_cfg;
static std::string Exec(const std::string& cmd) { static std::string Exec(const std::string &cmd)
std::array<char, 4096> buf{}; {
std::string out; std::array<char, 4096> buf{};
FILE* pipe = popen((cmd + " 2>&1").c_str(), "r"); std::string out;
if (!pipe) throw std::runtime_error("popen failed: " + cmd); FILE *pipe = popen((cmd + " 2>&1").c_str(), "r");
while (fgets(buf.data(), (int)buf.size(), pipe) != nullptr) { if (!pipe)
out.append(buf.data()); 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) { 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 (createParents)
if (!f) throw std::runtime_error("Cannot open file for write: " + p.string()); std::filesystem::create_directories(p.parent_path());
f.write(data.data(), (std::streamsize)data.size()); 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) { 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::ifstream f(p, std::ios::binary);
std::ostringstream ss; ss << f.rdbuf(); if (!f)
return ss.str(); 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) { 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"); auto b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return ""; auto e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1); if (b == std::string::npos)
} return "";
return s.substr(b, e - b + 1);
}
static std::string ParseCpuSerial() { static std::string ParseCpuSerial()
// Default for “empty” case: 12 times '9' {
std::string fallback(12, '9'); // Default for “empty” case: 12 times '9'
std::string fallback(12, '9');
std::ifstream f("/proc/cpuinfo"); std::ifstream f("/proc/cpuinfo");
if (!f) { if (!f)
spdlog::warn("/proc/cpuinfo not available, using fallback serial"); {
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; return fallback;
} }
std::string line;
std::regex re(R"(^\s*Serial\s*:\s*([0-9A-Fa-f]+)\s*$)"); public:
while (std::getline(f, line)) { explicit EnrollmentService(std::shared_ptr<ConfigService> cfg) : m_cfg(std::move(cfg)) {}
std::smatch m;
if (std::regex_match(line, m, re) && m.size() == 2) { // Returns true if enrollment was performed now; false if it was already done
auto serial = Trim(m[1].str()); bool EnsureEnrolled()
if (!serial.empty()) return serial; {
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: spdlog::info("Starting first-run enrollment...");
explicit EnrollmentService(std::shared_ptr<ConfigService> cfg) : m_cfg(std::move(cfg)) {}
// Returns true if enrollment was performed now; false if it was already done // 1) Run gen_device_csr.sh <GUID>
bool EnsureEnrolled() { {
const std::string guid = m_cfg->GetGuid(); // Assumes script is in the working dir or in PATH
const std::filesystem::path keystoreDir = "/etc/iot/keys"; const std::string cmd = "bash ./gen_device_csr.sh " + guid;
const auto encKeyPath = keystoreDir / "device.key.enc"; spdlog::info("Executing: {}", cmd);
const auto certPath = keystoreDir / "device.crt.pem"; auto out = Exec(cmd);
const auto caPath = keystoreDir / "issuing_ca.pem"; spdlog::debug("gen_device_csr.sh output:\n{}", out);
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 <GUID>
{
// 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());
} }
} 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":"<...>"}` if (!std::filesystem::exists(keyName) || !std::filesystem::exists(csrName))
std::string csrPem = ReadFile(csrName); {
{ throw std::runtime_error("CSR or key was not generated by gen_device_csr.sh");
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<std::string>());
if (j.contains("issuing_ca")) WriteFile(caPath, j["issuing_ca"].get<std::string>());
if (j.contains("ca_chain")) WriteFile(chainPath, j["ca_chain"].get<std::string>());
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;
} }
// 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<std::string>());
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<std::string>());
}
if (j.contains("ca_chain"))
{
std::string chainPem;
if (j["ca_chain"].is_string())
{
chainPem = j["ca_chain"].get<std::string>();
}
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<std::string>();
if (out.back() != '\n')
out.push_back('\n');
}
return out;
}
};
} // namespace snoop } // namespace snoop