microReticulumTbeam/tools/keygen/main.cpp
John Poole 84d947a3f0 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)
2026-02-12 11:17:48 -08:00

376 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// rns-provision - generate microReticulum identity keypairs + provisioning bundles
//
// 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>
#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:\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], 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");
quantity = std::stoi(argv[++i]);
} else if (is_flag(a, "-f") || is_flag(a, "--format")) {
if (i + 1 >= argc) throw std::runtime_error("Missing value for --format");
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], 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") {
std::cout << "n\tdevice\tid_hex\tprivate_key_hex";
if (include_public) std::cout << "\tpublic_key_hex";
std::cout << "\n";
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 {
std::cout << "[\n";
for (size_t i = 0; i < devices.size(); i++) {
const auto& d = devices[i];
std::cout << " {\n";
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\": \"" << d.pub_hex << "\"\n";
} else {
std::cout << "\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";
return 1;
}
}