Преглед на файлове

v2.4 Stage F: CLI helpers — generate-auth-key + generate-tls-cert

Adds two offline subcommands to smartbotic-db-cli for v2.4 listener
bootstrap, both runnable without a server connection.

generate-auth-key:
  - 32 bytes from getentropy() (fallback /dev/urandom).
  - Base64-encoded via OpenSSL EVP_EncodeBlock.
  - Prints one line, no decoration. Exit 0/1.
  - Operators paste into storage.listeners[i].auth.keys[] on the server
    and into Client::Config::auth_token on the client.

generate-tls-cert --bind <addr> [--out-cert PATH] [--out-key PATH] [--days N]:
  - 4096-bit RSA via the OpenSSL 3.x EVP_PKEY_keygen API (avoids the
    deprecation warnings from RSA_generate_key_ex on OpenSSL 3).
  - X.509 v3, SHA-256 self-signed, random 64-bit serial.
  - CN = bind. SubjectAltName always includes DNS:localhost +
    IP:127.0.0.1, and additionally adds the bind itself classified via
    inet_pton (IP:bind for literal IPv4/IPv6, DNS:bind for hostnames).
    No duplicate SAN entry when bind is already 'localhost' or
    '127.0.0.1'.
  - basicConstraints = critical,CA:FALSE — leaf cert, not a CA.
  - Validity defaults to 3650 days (10y).
  - Key written 0600, cert written 0644.
  - Defaults: --out-cert ./server.pem, --out-key ./server.key.

CLI dispatch jumps to these handlers before the gRPC Client connect, so
operators can pre-create artifacts on a fresh machine with no DB running.
help text now lists the two helpers under an 'Offline helpers' section.

Verified: openssl x509 -in <out> -text -noout parses cleanly, shows
CN + SAN + 10y validity, file modes are 0600/0644. ctest 14/14 green.
fszontagh преди 1 месец
родител
ревизия
c68171ed53
променени са 2 файла, в които са добавени 405 реда и са изтрити 1 реда
  1. 7 1
      cli/CMakeLists.txt
  2. 398 0
      cli/main.cpp

+ 7 - 1
cli/CMakeLists.txt

@@ -1,2 +1,8 @@
+find_package(OpenSSL REQUIRED)
+
 add_executable(smartbotic-db-cli main.cpp)
-target_link_libraries(smartbotic-db-cli PRIVATE smartbotic-db-client)
+target_link_libraries(smartbotic-db-cli PRIVATE
+    smartbotic-db-client
+    OpenSSL::SSL
+    OpenSSL::Crypto
+)

+ 398 - 0
cli/main.cpp

@@ -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 &params[++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();