|
|
@@ -1,6 +1,25 @@
|
|
|
#include <smartbotic/database/client.hpp>
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
|
|
+#include <openssl/bio.h>
|
|
|
+#include <openssl/bn.h>
|
|
|
+#include <openssl/err.h>
|
|
|
+#include <openssl/evp.h>
|
|
|
+#include <openssl/pem.h>
|
|
|
+#include <openssl/rand.h>
|
|
|
+#include <openssl/x509.h>
|
|
|
+#include <openssl/x509v3.h>
|
|
|
+
|
|
|
+#include <arpa/inet.h>
|
|
|
+#include <fcntl.h>
|
|
|
+#include <sys/random.h>
|
|
|
+#include <sys/stat.h>
|
|
|
+#include <unistd.h>
|
|
|
+
|
|
|
+#include <cerrno>
|
|
|
+#include <cstdio>
|
|
|
+#include <cstring>
|
|
|
+#include <fstream>
|
|
|
#include <iostream>
|
|
|
#include <sstream>
|
|
|
#include <string>
|
|
|
@@ -50,10 +69,380 @@ void printUsage() {
|
|
|
<< " " << C_CYAN << "unlock" << C_RESET << " Unlock database (accept writes)\n"
|
|
|
<< " " << C_CYAN << "status" << C_RESET << " Show read-only + recovery status\n"
|
|
|
<< " " << C_CYAN << "help" << C_RESET << " Show this help\n\n"
|
|
|
+ << C_BOLD << "Offline helpers" << C_RESET << " " << C_DIM << "(no server connection required)" << C_RESET << ":\n"
|
|
|
+ << " " << C_CYAN << "generate-auth-key" << C_RESET << " Emit a base64 32-byte API key\n"
|
|
|
+ << " " << C_CYAN << "generate-tls-cert" << C_RESET << " --bind <addr> Generate a self-signed TLS cert + key\n"
|
|
|
+ << " " << C_DIM << "[--out-cert PATH] [--out-key PATH] [--days N]" << C_RESET << "\n\n"
|
|
|
<< C_BOLD << "Options:" << C_RESET << "\n"
|
|
|
<< " --address HOST:PORT Database address (default: localhost:9004)\n";
|
|
|
}
|
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// v2.4 Stage F: offline helpers (no server connection required)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+// Base64-encode raw bytes. Uses OpenSSL's EVP_EncodeBlock which emits the
|
|
|
+// standard base64 alphabet (no newlines) and pads with '='.
|
|
|
+std::string base64Encode(const unsigned char* data, size_t len) {
|
|
|
+ if (len == 0) return {};
|
|
|
+ // EVP_EncodeBlock writes ((len + 2) / 3) * 4 bytes + a NUL terminator.
|
|
|
+ const size_t out_len = 4 * ((len + 2) / 3);
|
|
|
+ std::string out(out_len, '\0');
|
|
|
+ int written = EVP_EncodeBlock(
|
|
|
+ reinterpret_cast<unsigned char*>(out.data()),
|
|
|
+ data, static_cast<int>(len));
|
|
|
+ if (written < 0) return {};
|
|
|
+ out.resize(static_cast<size_t>(written));
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+// Pull 32 cryptographically random bytes from getentropy() (preferred) or
|
|
|
+// /dev/urandom (fallback). Returns true on success.
|
|
|
+bool fillRandomBytes(unsigned char* buf, size_t len) {
|
|
|
+ // getentropy() is limited to 256 bytes per call; our use-case is 32.
|
|
|
+ if (len <= 256) {
|
|
|
+ if (getentropy(buf, len) == 0) return true;
|
|
|
+ }
|
|
|
+ // Fallback: /dev/urandom.
|
|
|
+ int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC);
|
|
|
+ if (fd < 0) return false;
|
|
|
+ size_t got = 0;
|
|
|
+ while (got < len) {
|
|
|
+ ssize_t n = ::read(fd, buf + got, len - got);
|
|
|
+ if (n <= 0) {
|
|
|
+ if (errno == EINTR) continue;
|
|
|
+ ::close(fd);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ got += static_cast<size_t>(n);
|
|
|
+ }
|
|
|
+ ::close(fd);
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+int cmdGenerateAuthKey() {
|
|
|
+ unsigned char key[32];
|
|
|
+ if (!fillRandomBytes(key, sizeof(key))) {
|
|
|
+ std::fprintf(stderr, "error: failed to obtain entropy for key\n");
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ auto encoded = base64Encode(key, sizeof(key));
|
|
|
+ if (encoded.empty()) {
|
|
|
+ std::fprintf(stderr, "error: base64 encoding failed\n");
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ std::cout << encoded << "\n";
|
|
|
+ return 0;
|
|
|
+}
|
|
|
+
|
|
|
+namespace {
|
|
|
+
|
|
|
+// Print the topmost OpenSSL error to stderr with a prefix.
|
|
|
+void printOpenSslError(const char* prefix) {
|
|
|
+ unsigned long e = ERR_get_error();
|
|
|
+ char buf[256] = {0};
|
|
|
+ if (e != 0) {
|
|
|
+ ERR_error_string_n(e, buf, sizeof(buf));
|
|
|
+ std::fprintf(stderr, "error: %s: %s\n", prefix, buf);
|
|
|
+ } else {
|
|
|
+ std::fprintf(stderr, "error: %s\n", prefix);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Returns true if `s` parses as an IPv4 or IPv6 literal.
|
|
|
+bool looksLikeIp(const std::string& s) {
|
|
|
+ unsigned char buf[16];
|
|
|
+ if (inet_pton(AF_INET, s.c_str(), buf) == 1) return true;
|
|
|
+ if (inet_pton(AF_INET6, s.c_str(), buf) == 1) return true;
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+// Generate an RSA private key as an EVP_PKEY using the modern (3.x) API.
|
|
|
+EVP_PKEY* generateRsaKey(int bits) {
|
|
|
+ EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr);
|
|
|
+ if (!ctx) return nullptr;
|
|
|
+ EVP_PKEY* pkey = nullptr;
|
|
|
+ if (EVP_PKEY_keygen_init(ctx) <= 0) {
|
|
|
+ EVP_PKEY_CTX_free(ctx);
|
|
|
+ return nullptr;
|
|
|
+ }
|
|
|
+ if (EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0) {
|
|
|
+ EVP_PKEY_CTX_free(ctx);
|
|
|
+ return nullptr;
|
|
|
+ }
|
|
|
+ if (EVP_PKEY_keygen(ctx, &pkey) <= 0) {
|
|
|
+ EVP_PKEY_CTX_free(ctx);
|
|
|
+ return nullptr;
|
|
|
+ }
|
|
|
+ EVP_PKEY_CTX_free(ctx);
|
|
|
+ return pkey;
|
|
|
+}
|
|
|
+
|
|
|
+// Write a string to disk with the given file mode. Truncates existing files.
|
|
|
+bool writeFileWithMode(const std::string& path, const std::string& contents, mode_t mode) {
|
|
|
+ int fd = ::open(path.c_str(),
|
|
|
+ O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC,
|
|
|
+ mode);
|
|
|
+ if (fd < 0) {
|
|
|
+ std::fprintf(stderr, "error: open %s: %s\n", path.c_str(), std::strerror(errno));
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // Re-assert mode in case the file pre-existed with a wider mode and
|
|
|
+ // O_CREAT was a no-op (open(2) only applies the mode on create).
|
|
|
+ if (fchmod(fd, mode) != 0) {
|
|
|
+ std::fprintf(stderr, "error: fchmod %s: %s\n", path.c_str(), std::strerror(errno));
|
|
|
+ ::close(fd);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ size_t off = 0;
|
|
|
+ while (off < contents.size()) {
|
|
|
+ ssize_t n = ::write(fd, contents.data() + off, contents.size() - off);
|
|
|
+ if (n <= 0) {
|
|
|
+ if (errno == EINTR) continue;
|
|
|
+ std::fprintf(stderr, "error: write %s: %s\n", path.c_str(), std::strerror(errno));
|
|
|
+ ::close(fd);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ off += static_cast<size_t>(n);
|
|
|
+ }
|
|
|
+ if (::close(fd) != 0) {
|
|
|
+ std::fprintf(stderr, "error: close %s: %s\n", path.c_str(), std::strerror(errno));
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+// Serialize an X509* to a PEM string.
|
|
|
+std::string pemEncodeCert(X509* cert) {
|
|
|
+ BIO* bio = BIO_new(BIO_s_mem());
|
|
|
+ if (!bio) return {};
|
|
|
+ if (PEM_write_bio_X509(bio, cert) != 1) {
|
|
|
+ BIO_free(bio);
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+ BUF_MEM* mem = nullptr;
|
|
|
+ BIO_get_mem_ptr(bio, &mem);
|
|
|
+ std::string out(mem->data, mem->length);
|
|
|
+ BIO_free(bio);
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+// Serialize an EVP_PKEY* to an unencrypted PEM string (PKCS#8 format via
|
|
|
+// PEM_write_bio_PrivateKey).
|
|
|
+std::string pemEncodeKey(EVP_PKEY* key) {
|
|
|
+ BIO* bio = BIO_new(BIO_s_mem());
|
|
|
+ if (!bio) return {};
|
|
|
+ if (PEM_write_bio_PrivateKey(bio, key, nullptr, nullptr, 0, nullptr, nullptr) != 1) {
|
|
|
+ BIO_free(bio);
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+ BUF_MEM* mem = nullptr;
|
|
|
+ BIO_get_mem_ptr(bio, &mem);
|
|
|
+ std::string out(mem->data, mem->length);
|
|
|
+ BIO_free(bio);
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+} // anonymous namespace
|
|
|
+
|
|
|
+int cmdGenerateTlsCert(const std::vector<std::string>& params) {
|
|
|
+ std::string bind;
|
|
|
+ std::string out_cert = "./server.pem";
|
|
|
+ std::string out_key = "./server.key";
|
|
|
+ int days = 3650;
|
|
|
+
|
|
|
+ for (size_t i = 0; i < params.size(); ++i) {
|
|
|
+ const auto& p = params[i];
|
|
|
+ auto need = [&](const char* flag) -> const std::string* {
|
|
|
+ if (i + 1 >= params.size()) {
|
|
|
+ std::fprintf(stderr, "error: %s requires a value\n", flag);
|
|
|
+ return nullptr;
|
|
|
+ }
|
|
|
+ return ¶ms[++i];
|
|
|
+ };
|
|
|
+ if (p == "--bind") {
|
|
|
+ auto* v = need("--bind"); if (!v) return 2; bind = *v;
|
|
|
+ } else if (p == "--out-cert") {
|
|
|
+ auto* v = need("--out-cert"); if (!v) return 2; out_cert = *v;
|
|
|
+ } else if (p == "--out-key") {
|
|
|
+ auto* v = need("--out-key"); if (!v) return 2; out_key = *v;
|
|
|
+ } else if (p == "--days") {
|
|
|
+ auto* v = need("--days"); if (!v) return 2;
|
|
|
+ try { days = std::stoi(*v); }
|
|
|
+ catch (...) {
|
|
|
+ std::fprintf(stderr, "error: --days must be an integer\n");
|
|
|
+ return 2;
|
|
|
+ }
|
|
|
+ if (days <= 0) {
|
|
|
+ std::fprintf(stderr, "error: --days must be > 0\n");
|
|
|
+ return 2;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ std::fprintf(stderr, "error: unknown argument: %s\n", p.c_str());
|
|
|
+ return 2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (bind.empty()) {
|
|
|
+ std::fprintf(stderr,
|
|
|
+ "usage: generate-tls-cert --bind <addr> "
|
|
|
+ "[--out-cert PATH] [--out-key PATH] [--days N]\n");
|
|
|
+ return 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1. RSA 4096-bit key.
|
|
|
+ EVP_PKEY* pkey = generateRsaKey(4096);
|
|
|
+ if (!pkey) {
|
|
|
+ printOpenSslError("RSA key generation failed");
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. X.509 certificate.
|
|
|
+ X509* cert = X509_new();
|
|
|
+ if (!cert) {
|
|
|
+ printOpenSslError("X509_new failed");
|
|
|
+ EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Version 3 (the integer field encodes v3 as 2).
|
|
|
+ if (X509_set_version(cert, 2) != 1) {
|
|
|
+ printOpenSslError("X509_set_version failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Random 64-bit serial number.
|
|
|
+ {
|
|
|
+ unsigned char serial_bytes[8];
|
|
|
+ if (RAND_bytes(serial_bytes, sizeof(serial_bytes)) != 1) {
|
|
|
+ printOpenSslError("RAND_bytes for serial failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ // Mask the top bit so the BIGNUM is positive.
|
|
|
+ serial_bytes[0] &= 0x7F;
|
|
|
+ BIGNUM* bn = BN_bin2bn(serial_bytes, sizeof(serial_bytes), nullptr);
|
|
|
+ if (!bn) {
|
|
|
+ printOpenSslError("BN_bin2bn failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ ASN1_INTEGER* ai = BN_to_ASN1_INTEGER(bn, nullptr);
|
|
|
+ BN_free(bn);
|
|
|
+ if (!ai) {
|
|
|
+ printOpenSslError("BN_to_ASN1_INTEGER failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (X509_set_serialNumber(cert, ai) != 1) {
|
|
|
+ printOpenSslError("X509_set_serialNumber failed");
|
|
|
+ ASN1_INTEGER_free(ai);
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ ASN1_INTEGER_free(ai);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validity period.
|
|
|
+ if (!X509_gmtime_adj(X509_get_notBefore(cert), 0)) {
|
|
|
+ printOpenSslError("X509_gmtime_adj(notBefore) failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ long seconds = static_cast<long>(days) * 24L * 60L * 60L;
|
|
|
+ if (!X509_gmtime_adj(X509_get_notAfter(cert), seconds)) {
|
|
|
+ printOpenSslError("X509_gmtime_adj(notAfter) failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Public key.
|
|
|
+ if (X509_set_pubkey(cert, pkey) != 1) {
|
|
|
+ printOpenSslError("X509_set_pubkey failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Subject + issuer name (self-signed, so identical).
|
|
|
+ X509_NAME* name = X509_get_subject_name(cert);
|
|
|
+ if (X509_NAME_add_entry_by_txt(
|
|
|
+ name, "CN", MBSTRING_UTF8,
|
|
|
+ reinterpret_cast<const unsigned char*>(bind.c_str()),
|
|
|
+ -1, -1, 0) != 1) {
|
|
|
+ printOpenSslError("X509_NAME_add_entry_by_txt(CN) failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (X509_set_issuer_name(cert, name) != 1) {
|
|
|
+ printOpenSslError("X509_set_issuer_name failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // SubjectAltName.
|
|
|
+ {
|
|
|
+ std::string san = "DNS:localhost,IP:127.0.0.1";
|
|
|
+ if (bind != "localhost" && bind != "127.0.0.1") {
|
|
|
+ san += ',';
|
|
|
+ san += looksLikeIp(bind) ? "IP:" : "DNS:";
|
|
|
+ san += bind;
|
|
|
+ }
|
|
|
+ X509_EXTENSION* ext = X509V3_EXT_conf_nid(
|
|
|
+ nullptr, nullptr, NID_subject_alt_name, san.c_str());
|
|
|
+ if (!ext) {
|
|
|
+ printOpenSslError("X509V3_EXT_conf_nid(SAN) failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ if (X509_add_ext(cert, ext, -1) != 1) {
|
|
|
+ printOpenSslError("X509_add_ext(SAN) failed");
|
|
|
+ X509_EXTENSION_free(ext);
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ X509_EXTENSION_free(ext);
|
|
|
+ }
|
|
|
+
|
|
|
+ // basicConstraints CA:FALSE — a server leaf cert, not a CA.
|
|
|
+ {
|
|
|
+ X509_EXTENSION* ext = X509V3_EXT_conf_nid(
|
|
|
+ nullptr, nullptr, NID_basic_constraints, "critical,CA:FALSE");
|
|
|
+ if (ext) {
|
|
|
+ X509_add_ext(cert, ext, -1);
|
|
|
+ X509_EXTENSION_free(ext);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sign with SHA-256.
|
|
|
+ if (X509_sign(cert, pkey, EVP_sha256()) == 0) {
|
|
|
+ printOpenSslError("X509_sign failed");
|
|
|
+ X509_free(cert); EVP_PKEY_free(pkey);
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Serialize.
|
|
|
+ std::string cert_pem = pemEncodeCert(cert);
|
|
|
+ std::string key_pem = pemEncodeKey(pkey);
|
|
|
+ X509_free(cert);
|
|
|
+ EVP_PKEY_free(pkey);
|
|
|
+
|
|
|
+ if (cert_pem.empty() || key_pem.empty()) {
|
|
|
+ std::fprintf(stderr, "error: PEM encoding failed\n");
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Write key first (0600), then cert (0644). If either fails, the other
|
|
|
+ // may have been written — that's acceptable; the operator will see the
|
|
|
+ // error and retry.
|
|
|
+ if (!writeFileWithMode(out_key, key_pem, 0600)) return 1;
|
|
|
+ if (!writeFileWithMode(out_cert, cert_pem, 0644)) return 1;
|
|
|
+
|
|
|
+ std::cout << "Wrote cert: " << out_cert << "\n"
|
|
|
+ << "Wrote key: " << out_key << "\n";
|
|
|
+ return 0;
|
|
|
+}
|
|
|
+
|
|
|
bool execCommand(smartbotic::database::Client& client,
|
|
|
const std::string& cmd, const std::vector<std::string>& params) {
|
|
|
try {
|
|
|
@@ -283,6 +672,15 @@ int main(int argc, char* argv[]) {
|
|
|
args.params.assign(positional.begin() + 1, positional.end());
|
|
|
}
|
|
|
|
|
|
+ // Offline helpers — these don't need a running server, so dispatch
|
|
|
+ // them before opening the gRPC channel.
|
|
|
+ if (args.command == "generate-auth-key") {
|
|
|
+ return cmdGenerateAuthKey();
|
|
|
+ }
|
|
|
+ if (args.command == "generate-tls-cert") {
|
|
|
+ return cmdGenerateTlsCert(args.params);
|
|
|
+ }
|
|
|
+
|
|
|
// Connect
|
|
|
smartbotic::database::Client client({.address = args.address});
|
|
|
client.connect();
|