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:
John Poole 2026-02-12 11:17:48 -08:00
commit 84d947a3f0
2 changed files with 311 additions and 32 deletions

View file

@ -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)

View file

@ -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 <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 <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
<< "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<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, well 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) {
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<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") {
// 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";