From 84d947a3f0730f2414086f8ad68b0367557d5e21 Mon Sep 17 00:00:00 2001 From: John Poole Date: Thu, 12 Feb 2026 11:17:48 -0800 Subject: [PATCH] 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) --- tools/keygen/CMakeLists.txt | 2 + tools/keygen/main.cpp | 341 ++++++++++++++++++++++++++++++++---- 2 files changed, 311 insertions(+), 32 deletions(-) diff --git a/tools/keygen/CMakeLists.txt b/tools/keygen/CMakeLists.txt index b513a15..03b1797 100644 --- a/tools/keygen/CMakeLists.txt +++ b/tools/keygen/CMakeLists.txt @@ -20,3 +20,5 @@ target_include_directories(rns-provision PRIVATE # Link against the microReticulum library target. # If this target name is wrong in your submodule, change it here. target_link_libraries(rns-provision PRIVATE ReticulumStatic) + +install(TARGETS rns-provision RUNTIME DESTINATION bin) diff --git a/tools/keygen/main.cpp b/tools/keygen/main.cpp index d593f2d..18cc6d0 100644 --- a/tools/keygen/main.cpp +++ b/tools/keygen/main.cpp @@ -1,39 +1,194 @@ -// keygen - generate microReticulum identity keypairs +// rns-provision - generate microReticulum identity keypairs + provisioning bundles // -// Example: -// ./keygen --quantity 6 --format tsv -// ./keygen -q 6 -f json +// Examples: +// ./rns-provision --quantity 6 --format tsv +// ./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$ // $Id$ #include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include -#include -#include -static void usage(const char* argv0) { +#if __has_include() + #include + namespace fs = std::filesystem; +#else + #error "This tool requires (C++17)." +#endif + +#ifndef _WIN32 + #include +#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 - << "Usage: " << argv0 << " --quantity N [--format tsv|json] [--public]\n" - << " -q, --quantity Number of identities to generate (required)\n" - << " -f, --format Output format: tsv (default) or json\n" - << " --public Also include public_key in output\n"; + << "Usage:\n" + << " " << argv0 << " --quantity N [options]\n\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 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& 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(bytes.data()), static_cast(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. +// This assumes the returned object supports .size() and operator[]. +// If your microReticulum Bytes type differs, we’ll adjust here. +template +static std::vector to_u8vec(const BytesT& b) { + std::vector v; + v.reserve(static_cast(b.size())); + for (size_t i = 0; i < static_cast(b.size()); i++) { + v.push_back(static_cast(b[i])); + } + return v; +} + int main(int argc, char** argv) { try { int quantity = -1; std::string format = "tsv"; 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++) { std::string a(argv[i]); if (is_flag(a, "-h") || is_flag(a, "--help")) { - usage(argv[0]); + usage(argv[0], true); return 0; } else if (is_flag(a, "-q") || is_flag(a, "--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]; } else if (is_flag(a, "--public")) { 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 { throw std::runtime_error("Unknown argument: " + a); } } if (quantity <= 0) { - usage(argv[0]); + usage(argv[0], true); return 2; } if (!(format == "tsv" || format == "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 priv_bin; + fs::path device_dir; + }; + + std::vector devices; + devices.reserve(static_cast(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") { - // header row - std::cout << "n\tid_hex\tprivate_key_hex"; + std::cout << "n\tdevice\tid_hex\tprivate_key_hex"; if (include_public) std::cout << "\tpublic_key_hex"; std::cout << "\n"; - - for (int n = 1; n <= quantity; n++) { - RNS::Identity id(true); - std::cout - << n << "\t" - << id.hash().toHex() << "\t" - << id.get_private_key().toHex(); - if (include_public) std::cout << "\t" << id.get_public_key().toHex(); + for (const auto& d : devices) { + std::cout << d.n << "\t" << d.device_name << "\t" << d.id_hex << "\t" << d.priv_hex; + if (include_public) std::cout << "\t" << d.pub_hex; std::cout << "\n"; } } else { - // json std::cout << "[\n"; - for (int n = 1; n <= quantity; n++) { - RNS::Identity id(true); - + for (size_t i = 0; i < devices.size(); i++) { + const auto& d = devices[i]; std::cout << " {\n"; - std::cout << " \"n\": " << n << ",\n"; - std::cout << " \"id\": \"" << id.hash().toHex() << "\",\n"; - std::cout << " \"private_key\": \"" << id.get_private_key().toHex() << "\""; + std::cout << " \"n\": " << d.n << ",\n"; + std::cout << " \"device\": \"" << json_escape(d.device_name) << "\",\n"; + std::cout << " \"id\": \"" << d.id_hex << "\",\n"; + std::cout << " \"private_key\": \"" << d.priv_hex << "\""; if (include_public) { - std::cout << ",\n \"public_key\": \"" << id.get_public_key().toHex() << "\"\n"; + std::cout << ",\n \"public_key\": \"" << d.pub_hex << "\"\n"; } else { std::cout << "\n"; } - std::cout << " }" << (n == quantity ? "\n" : ",\n"); + std::cout << " }" << (i + 1 == devices.size() ? "\n" : ",\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; } catch (const std::exception& e) { std::cerr << "Error: " << e.what() << "\n";