Add host-side rns-provision tool and portable build infrastructure
- Add tools/keygen/rns-provision CLI - Generate Reticulum identities with TSV/JSON output - Add provisioning bundle support (--outdir, --bundle) - Write manifest.json + per-device identity.json/bin + label.txt - Enforce 0600 permissions on private key material - Add -h/--help and version output - Make build portable across distros (msgpack shim targets) - Integrate ArduinoJson include paths - Disable ReticulumShared build (static-only)
This commit is contained in:
parent
18e8d2c8ea
commit
84d947a3f0
2 changed files with 311 additions and 32 deletions
|
|
@ -20,3 +20,5 @@ target_include_directories(rns-provision PRIVATE
|
||||||
# Link against the microReticulum library target.
|
# Link against the microReticulum library target.
|
||||||
# If this target name is wrong in your submodule, change it here.
|
# If this target name is wrong in your submodule, change it here.
|
||||||
target_link_libraries(rns-provision PRIVATE ReticulumStatic)
|
target_link_libraries(rns-provision PRIVATE ReticulumStatic)
|
||||||
|
|
||||||
|
install(TARGETS rns-provision RUNTIME DESTINATION bin)
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,194 @@
|
||||||
// keygen - generate microReticulum identity keypairs
|
// rns-provision - generate microReticulum identity keypairs + provisioning bundles
|
||||||
//
|
//
|
||||||
// Example:
|
// Examples:
|
||||||
// ./keygen --quantity 6 --format tsv
|
// ./rns-provision --quantity 6 --format tsv
|
||||||
// ./keygen -q 6 -f json
|
// ./rns-provision -q 6 -f json --public
|
||||||
|
// ./rns-provision -q 6 --outdir provisioning/20260212_1030 --bundle both --public
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - Writes sensitive material (private keys). Protect your output directories.
|
||||||
|
// - identity.bin is raw private key bytes as returned by microReticulum Identity.
|
||||||
//
|
//
|
||||||
// $Header$
|
// $Header$
|
||||||
// $Id$
|
// $Id$
|
||||||
|
|
||||||
#include <Identity.h>
|
#include <Identity.h>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <exception>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <iostream>
|
|
||||||
#include <stdexcept>
|
|
||||||
|
|
||||||
static void usage(const char* argv0) {
|
#if __has_include(<filesystem>)
|
||||||
|
#include <filesystem>
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
#else
|
||||||
|
#error "This tool requires <filesystem> (C++17)."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static constexpr const char* TOOL_NAME = "rns-provision";
|
||||||
|
static constexpr const char* TOOL_VERSION = "0.1.0";
|
||||||
|
|
||||||
|
static void usage(const char* argv0, bool full = true) {
|
||||||
|
std::cerr << TOOL_NAME << " " << TOOL_VERSION << "\n\n";
|
||||||
|
|
||||||
std::cerr
|
std::cerr
|
||||||
<< "Usage: " << argv0 << " --quantity N [--format tsv|json] [--public]\n"
|
<< "Usage:\n"
|
||||||
<< " -q, --quantity Number of identities to generate (required)\n"
|
<< " " << argv0 << " --quantity N [options]\n\n";
|
||||||
<< " -f, --format Output format: tsv (default) or json\n"
|
|
||||||
<< " --public Also include public_key in output\n";
|
if (!full) return;
|
||||||
|
|
||||||
|
std::cerr
|
||||||
|
<< "Options:\n"
|
||||||
|
<< " -q, --quantity N Number of identities to generate (required)\n"
|
||||||
|
<< " -f, --format FMT Stdout format: tsv (default) or json\n"
|
||||||
|
<< " --public Include public_key in stdout and identity.json\n"
|
||||||
|
<< " --outdir DIR Write provisioning bundle to DIR\n"
|
||||||
|
<< " --bundle MODE none|json|bin|both (default: both if --outdir)\n"
|
||||||
|
<< " --prefix NAME Device directory prefix (default: device)\n"
|
||||||
|
<< " -h, --help Show this help message and exit\n"
|
||||||
|
<< "\n"
|
||||||
|
<< "Examples:\n"
|
||||||
|
<< " " << argv0 << " -q 6\n"
|
||||||
|
<< " " << argv0 << " -q 6 --outdir provisioning/20260212_1030 --bundle both\n"
|
||||||
|
<< "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool is_flag(const std::string& a, const char* s) { return a == s; }
|
static bool is_flag(const std::string& a, const char* s) { return a == s; }
|
||||||
|
|
||||||
|
static std::string utc_now_iso8601() {
|
||||||
|
using namespace std::chrono;
|
||||||
|
auto now = system_clock::now();
|
||||||
|
std::time_t t = system_clock::to_time_t(now);
|
||||||
|
std::tm tm{};
|
||||||
|
#ifdef _WIN32
|
||||||
|
gmtime_s(&tm, &t);
|
||||||
|
#else
|
||||||
|
gmtime_r(&t, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string json_escape(const std::string& s) {
|
||||||
|
std::ostringstream o;
|
||||||
|
for (unsigned char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '\"': o << "\\\""; break;
|
||||||
|
case '\\': o << "\\\\"; break;
|
||||||
|
case '\b': o << "\\b"; break;
|
||||||
|
case '\f': o << "\\f"; break;
|
||||||
|
case '\n': o << "\\n"; break;
|
||||||
|
case '\r': o << "\\r"; break;
|
||||||
|
case '\t': o << "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) {
|
||||||
|
o << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c);
|
||||||
|
} else {
|
||||||
|
o << c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string zpad_int(int value, int width) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::setw(width) << std::setfill('0') << value;
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensure_dir(const fs::path& p) {
|
||||||
|
std::error_code ec;
|
||||||
|
if (fs::exists(p, ec)) {
|
||||||
|
if (!fs::is_directory(p, ec)) {
|
||||||
|
throw std::runtime_error("Path exists but is not a directory: " + p.string());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fs::create_directories(p, ec)) {
|
||||||
|
throw std::runtime_error("Failed to create directory: " + p.string() + " (" + ec.message() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write_text_file(const fs::path& p, const std::string& data) {
|
||||||
|
std::ofstream out(p, std::ios::out | std::ios::trunc);
|
||||||
|
if (!out) throw std::runtime_error("Failed to open for write: " + p.string());
|
||||||
|
out << data;
|
||||||
|
out.close();
|
||||||
|
if (!out) throw std::runtime_error("Failed writing file: " + p.string());
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
// Best-effort: 0600 for sensitive text files (private keys)
|
||||||
|
::chmod(p.c_str(), 0600);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write_binary_file(const fs::path& p, const std::vector<uint8_t>& bytes) {
|
||||||
|
std::ofstream out(p, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out) throw std::runtime_error("Failed to open for write: " + p.string());
|
||||||
|
out.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||||||
|
out.close();
|
||||||
|
if (!out) throw std::runtime_error("Failed writing file: " + p.string());
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
::chmod(p.c_str(), 0600);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BundleMode { NONE, JSON, BIN, BOTH };
|
||||||
|
|
||||||
|
static BundleMode parse_bundle_mode(const std::string& s) {
|
||||||
|
if (s == "none") return BundleMode::NONE;
|
||||||
|
if (s == "json") return BundleMode::JSON;
|
||||||
|
if (s == "bin") return BundleMode::BIN;
|
||||||
|
if (s == "both") return BundleMode::BOTH;
|
||||||
|
throw std::runtime_error("Invalid --bundle value (must be none|json|bin|both)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert microReticulum Bytes-ish type to std::vector<uint8_t>.
|
||||||
|
// This assumes the returned object supports .size() and operator[].
|
||||||
|
// If your microReticulum Bytes type differs, we’ll adjust here.
|
||||||
|
template <typename BytesT>
|
||||||
|
static std::vector<uint8_t> to_u8vec(const BytesT& b) {
|
||||||
|
std::vector<uint8_t> v;
|
||||||
|
v.reserve(static_cast<size_t>(b.size()));
|
||||||
|
for (size_t i = 0; i < static_cast<size_t>(b.size()); i++) {
|
||||||
|
v.push_back(static_cast<uint8_t>(b[i]));
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
try {
|
try {
|
||||||
int quantity = -1;
|
int quantity = -1;
|
||||||
std::string format = "tsv";
|
std::string format = "tsv";
|
||||||
bool include_public = false;
|
bool include_public = false;
|
||||||
|
|
||||||
|
bool do_outdir = false;
|
||||||
|
fs::path outdir;
|
||||||
|
std::string prefix = "device";
|
||||||
|
BundleMode bundle_mode = BundleMode::NONE;
|
||||||
|
bool bundle_mode_explicit = false;
|
||||||
|
|
||||||
for (int i = 1; i < argc; i++) {
|
for (int i = 1; i < argc; i++) {
|
||||||
std::string a(argv[i]);
|
std::string a(argv[i]);
|
||||||
|
|
||||||
if (is_flag(a, "-h") || is_flag(a, "--help")) {
|
if (is_flag(a, "-h") || is_flag(a, "--help")) {
|
||||||
usage(argv[0]);
|
usage(argv[0], true);
|
||||||
return 0;
|
return 0;
|
||||||
} else if (is_flag(a, "-q") || is_flag(a, "--quantity")) {
|
} else if (is_flag(a, "-q") || is_flag(a, "--quantity")) {
|
||||||
if (i + 1 >= argc) throw std::runtime_error("Missing value for --quantity");
|
if (i + 1 >= argc) throw std::runtime_error("Missing value for --quantity");
|
||||||
|
|
@ -43,54 +198,176 @@ int main(int argc, char** argv) {
|
||||||
format = argv[++i];
|
format = argv[++i];
|
||||||
} else if (is_flag(a, "--public")) {
|
} else if (is_flag(a, "--public")) {
|
||||||
include_public = true;
|
include_public = true;
|
||||||
|
} else if (is_flag(a, "--outdir")) {
|
||||||
|
if (i + 1 >= argc) throw std::runtime_error("Missing value for --outdir");
|
||||||
|
outdir = fs::path(argv[++i]);
|
||||||
|
do_outdir = true;
|
||||||
|
} else if (is_flag(a, "--bundle")) {
|
||||||
|
if (i + 1 >= argc) throw std::runtime_error("Missing value for --bundle");
|
||||||
|
bundle_mode = parse_bundle_mode(argv[++i]);
|
||||||
|
bundle_mode_explicit = true;
|
||||||
|
} else if (is_flag(a, "--prefix")) {
|
||||||
|
if (i + 1 >= argc) throw std::runtime_error("Missing value for --prefix");
|
||||||
|
prefix = argv[++i];
|
||||||
|
} else if (is_flag(a, "--version")) {
|
||||||
|
std::cout << TOOL_NAME << " " << TOOL_VERSION << "\n";
|
||||||
|
return 0;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw std::runtime_error("Unknown argument: " + a);
|
throw std::runtime_error("Unknown argument: " + a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
usage(argv[0]);
|
usage(argv[0], true);
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
if (!(format == "tsv" || format == "json")) {
|
if (!(format == "tsv" || format == "json")) {
|
||||||
throw std::runtime_error("Invalid --format (must be tsv or json)");
|
throw std::runtime_error("Invalid --format (must be tsv or json)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default bundle behavior: if user set --outdir but not --bundle, write both.
|
||||||
|
if (do_outdir && !bundle_mode_explicit) {
|
||||||
|
bundle_mode = BundleMode::BOTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (do_outdir) {
|
||||||
|
ensure_dir(outdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceRec {
|
||||||
|
int n;
|
||||||
|
std::string device_name;
|
||||||
|
std::string id_hex;
|
||||||
|
std::string priv_hex;
|
||||||
|
std::string pub_hex;
|
||||||
|
std::vector<uint8_t> priv_bin;
|
||||||
|
fs::path device_dir;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<DeviceRec> devices;
|
||||||
|
devices.reserve(static_cast<size_t>(quantity));
|
||||||
|
|
||||||
|
for (int n = 1; n <= quantity; n++) {
|
||||||
|
RNS::Identity ident(true);
|
||||||
|
|
||||||
|
DeviceRec rec;
|
||||||
|
rec.n = n;
|
||||||
|
rec.device_name = prefix + "_" + zpad_int(n, 3);
|
||||||
|
|
||||||
|
// These methods matched your earlier build. If microReticulum changes, we adjust here.
|
||||||
|
rec.id_hex = ident.hash().toHex();
|
||||||
|
rec.priv_hex = ident.get_private_key().toHex();
|
||||||
|
if (include_public) rec.pub_hex = ident.get_public_key().toHex();
|
||||||
|
|
||||||
|
// Write binary blob for the private key
|
||||||
|
rec.priv_bin = to_u8vec(ident.get_private_key());
|
||||||
|
|
||||||
|
if (do_outdir) {
|
||||||
|
rec.device_dir = outdir / rec.device_name;
|
||||||
|
ensure_dir(rec.device_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.push_back(std::move(rec));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stdout ----
|
||||||
if (format == "tsv") {
|
if (format == "tsv") {
|
||||||
// header row
|
std::cout << "n\tdevice\tid_hex\tprivate_key_hex";
|
||||||
std::cout << "n\tid_hex\tprivate_key_hex";
|
|
||||||
if (include_public) std::cout << "\tpublic_key_hex";
|
if (include_public) std::cout << "\tpublic_key_hex";
|
||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
|
for (const auto& d : devices) {
|
||||||
for (int n = 1; n <= quantity; n++) {
|
std::cout << d.n << "\t" << d.device_name << "\t" << d.id_hex << "\t" << d.priv_hex;
|
||||||
RNS::Identity id(true);
|
if (include_public) std::cout << "\t" << d.pub_hex;
|
||||||
std::cout
|
|
||||||
<< n << "\t"
|
|
||||||
<< id.hash().toHex() << "\t"
|
|
||||||
<< id.get_private_key().toHex();
|
|
||||||
if (include_public) std::cout << "\t" << id.get_public_key().toHex();
|
|
||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// json
|
|
||||||
std::cout << "[\n";
|
std::cout << "[\n";
|
||||||
for (int n = 1; n <= quantity; n++) {
|
for (size_t i = 0; i < devices.size(); i++) {
|
||||||
RNS::Identity id(true);
|
const auto& d = devices[i];
|
||||||
|
|
||||||
std::cout << " {\n";
|
std::cout << " {\n";
|
||||||
std::cout << " \"n\": " << n << ",\n";
|
std::cout << " \"n\": " << d.n << ",\n";
|
||||||
std::cout << " \"id\": \"" << id.hash().toHex() << "\",\n";
|
std::cout << " \"device\": \"" << json_escape(d.device_name) << "\",\n";
|
||||||
std::cout << " \"private_key\": \"" << id.get_private_key().toHex() << "\"";
|
std::cout << " \"id\": \"" << d.id_hex << "\",\n";
|
||||||
|
std::cout << " \"private_key\": \"" << d.priv_hex << "\"";
|
||||||
if (include_public) {
|
if (include_public) {
|
||||||
std::cout << ",\n \"public_key\": \"" << id.get_public_key().toHex() << "\"\n";
|
std::cout << ",\n \"public_key\": \"" << d.pub_hex << "\"\n";
|
||||||
} else {
|
} else {
|
||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
}
|
}
|
||||||
std::cout << " }" << (n == quantity ? "\n" : ",\n");
|
std::cout << " }" << (i + 1 == devices.size() ? "\n" : ",\n");
|
||||||
}
|
}
|
||||||
std::cout << "]\n";
|
std::cout << "]\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- bundle output ----
|
||||||
|
if (do_outdir && bundle_mode != BundleMode::NONE) {
|
||||||
|
const std::string created_utc = utc_now_iso8601();
|
||||||
|
|
||||||
|
// manifest.json
|
||||||
|
{
|
||||||
|
std::ostringstream m;
|
||||||
|
m << "{\n";
|
||||||
|
m << " \"schema_version\": 1,\n";
|
||||||
|
m << " \"created_utc\": \"" << created_utc << "\",\n";
|
||||||
|
m << " \"quantity\": " << quantity << ",\n";
|
||||||
|
m << " \"prefix\": \"" << json_escape(prefix) << "\",\n";
|
||||||
|
m << " \"devices\": [\n";
|
||||||
|
for (size_t i = 0; i < devices.size(); i++) {
|
||||||
|
const auto& d = devices[i];
|
||||||
|
m << " {\n";
|
||||||
|
m << " \"n\": " << d.n << ",\n";
|
||||||
|
m << " \"device\": \"" << json_escape(d.device_name) << "\",\n";
|
||||||
|
m << " \"id\": \"" << d.id_hex << "\"";
|
||||||
|
if (bundle_mode == BundleMode::JSON || bundle_mode == BundleMode::BOTH) {
|
||||||
|
m << ",\n \"identity_json\": \"" << json_escape((d.device_name + "/identity.json")) << "\"";
|
||||||
|
}
|
||||||
|
if (bundle_mode == BundleMode::BIN || bundle_mode == BundleMode::BOTH) {
|
||||||
|
m << ",\n \"identity_bin\": \"" << json_escape((d.device_name + "/identity.bin")) << "\"";
|
||||||
|
}
|
||||||
|
m << "\n }" << (i + 1 == devices.size() ? "\n" : ",\n");
|
||||||
|
}
|
||||||
|
m << " ]\n";
|
||||||
|
m << "}\n";
|
||||||
|
write_text_file(outdir / "manifest.json", m.str());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& d : devices) {
|
||||||
|
// label.txt
|
||||||
|
{
|
||||||
|
std::ostringstream l;
|
||||||
|
l << d.device_name << "\n";
|
||||||
|
l << "id: " << d.id_hex << "\n";
|
||||||
|
write_text_file(d.device_dir / "label.txt", l.str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// identity.json
|
||||||
|
if (bundle_mode == BundleMode::JSON || bundle_mode == BundleMode::BOTH) {
|
||||||
|
std::ostringstream j;
|
||||||
|
j << "{\n";
|
||||||
|
j << " \"schema_version\": 1,\n";
|
||||||
|
j << " \"created_utc\": \"" << created_utc << "\",\n";
|
||||||
|
j << " \"device\": \"" << json_escape(d.device_name) << "\",\n";
|
||||||
|
j << " \"id\": \"" << d.id_hex << "\",\n";
|
||||||
|
j << " \"private_key\": \"" << d.priv_hex << "\"";
|
||||||
|
if (include_public) {
|
||||||
|
j << ",\n \"public_key\": \"" << d.pub_hex << "\"\n";
|
||||||
|
} else {
|
||||||
|
j << "\n";
|
||||||
|
}
|
||||||
|
j << "}\n";
|
||||||
|
write_text_file(d.device_dir / "identity.json", j.str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// identity.bin
|
||||||
|
if (bundle_mode == BundleMode::BIN || bundle_mode == BundleMode::BOTH) {
|
||||||
|
write_binary_file(d.device_dir / "identity.bin", d.priv_bin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cerr << "Wrote provisioning bundle to: " << outdir.string() << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::cerr << "Error: " << e.what() << "\n";
|
std::cerr << "Error: " << e.what() << "\n";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue