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 "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<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;
}
// ----------------------- Helpers (HTTPS mTLS) -----------------------
struct Url
{
@@ -216,7 +171,7 @@ namespace snoop
{
#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());
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<std::filesystem::path> tmpKey;
try
{
tmpKey = ExtractClientKeyFromKernelKeyring();
tmpKey = snoop::device_sec::ExtractClientKeyFromKernelKeyring();
}
catch (const std::exception &e)
{

View File

@@ -8,94 +8,110 @@
#include <fstream>
#include <sstream>
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<Config> m_config;
std::string m_configFilePath;
std::mutex m_mutex;
class ConfigService
{
std::shared_ptr<Config> 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<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 {
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());
}
};
}

View File

@@ -21,6 +21,7 @@
#include <sys/wait.h> // 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<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
{
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<httplib::SSLClient> MakeClient(const Url &u,
@@ -257,7 +207,7 @@ namespace snoop
{
#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());
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<std::filesystem::path> tmpKey;
try
{
tmpKey = ExtractClientKeyFromKernelKeyring();
tmpKey = snoop::device_sec::ExtractClientKeyFromKernelKeyring();
}
catch (const std::exception &e)
{

View File

@@ -17,187 +17,296 @@
#include "ConfigService.h"
namespace snoop {
namespace snoop
{
class EnrollmentService {
std::shared_ptr<ConfigService> m_cfg;
class EnrollmentService
{
std::shared_ptr<ConfigService> m_cfg;
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());
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());
}
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<ConfigService> 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<ConfigService> 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 <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());
// 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";
// 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<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;
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<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