some code cleanup, added dedicated function for ssl client private key extraction from keyring
This commit is contained in:
108
src/Security/TlsKeyUtil.h
Normal file
108
src/Security/TlsKeyUtil.h
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user