Jelajahi Sumber

feat: trust a project's certificates in a workflow, without turning verification off

Reaching a host with a self-signed or privately-issued certificate meant
skipTlsVerify, which stops checking the chain for that request and takes
whatever the connection hands back. This adds the other way round: name the
certificate you trust, and keep every check on.

Certificates are managed per project and assigned to a workflow in its
settings, so the trust decision lives with the flow rather than being repeated
in every node's config - and every node that makes an HTTPS request through
smartbotic.http.request gets it, without a config field each.

  certificates collection    public, project-scoped, PEM parsed on write
                             (subject, issuer, validity, sha256 fingerprint)
                             and refused if it does not parse
  workflow settings          certificateIds: [...]
  runner                     resolved once per run, applied per request

Three decisions worth keeping:

Host-bound. Each certificate names the hosts it is for, exact or a single
leading *. label, and the runner applies it only to those. An anchor vouches
for whatever it is asked about, so one added for an internal host must not also
be able to vouch for github.com.

Added to the trust store, not swapped in. CURLOPT_SSL_CTX_FUNCTION adds to the
store curl already loaded the system bundle into; CURLOPT_CAINFO_BLOB would
have replaced it and broken every public HTTPS call the flow makes. That case
is one of the tests.

A bad reference fails the run before any node has done anything - a missing
certificate, or one from another project. Running on would send the request
with less trust than the author asked for, and the TLS failure that followed
would point at the server rather than at the certificate somebody deleted.

The runner resolves them itself rather than being handed them at dispatch,
because several runners share the load and the resume and scheduled paths do
not go through the same dispatch code.

Verified against a real TLS server: a self-signed host answers 200 with the
certificate assigned and is refused without it, a name mismatch is still
refused, a public host still answers with a certificate assigned, and a
deleted or foreign certificate refuses the run by name.

Also corrects something I got wrong on the way: skipTlsVerify was never broken
for self-signed certificates. The receive error I saw came from the Python
http.server I was testing against, which resets the connection after the
handshake. Both routes work; this one keeps the checks.
fszontagh 1 bulan lalu
induk
melakukan
512a2be357

+ 21 - 0
CMakeLists.txt

@@ -83,6 +83,22 @@ target_link_libraries(smartbotic_settings PUBLIC
     nlohmann_json::nlohmann_json
 )
 
+# Certificates library - PEM parsing and host matching, shared by the webserver
+# (which validates and shows what a certificate is) and the runner (which
+# decides whether an anchor applies to a request). One implementation of the
+# host rule, deliberately: two would drift.
+add_library(smartbotic_certs STATIC
+    lib/certs/pem_info.cpp
+)
+target_include_directories(smartbotic_certs PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/lib
+)
+target_link_libraries(smartbotic_certs PUBLIC
+    smartbotic_common
+    OpenSSL::Crypto
+    nlohmann_json::nlohmann_json
+)
+
 # Crypto library
 add_library(smartbotic_crypto STATIC
     lib/crypto/aes_gcm.cpp
@@ -183,6 +199,7 @@ add_executable(smartbotic-webserver
     src/webserver/grpc/credential_service.cpp
     src/webserver/api/auth_controller.cpp
     src/webserver/api/credential_controller.cpp
+    src/webserver/api/certificate_controller.cpp
     src/webserver/api/user_controller.cpp
     src/webserver/api/dispatch_pool.cpp
     src/webserver/api/workflow_controller.cpp
@@ -208,6 +225,7 @@ target_compile_definitions(smartbotic-webserver PRIVATE
 )
 target_link_libraries(smartbotic-webserver PRIVATE
     smartbotic_common
+    smartbotic_certs
     smartbotic_logging
     smartbotic_config
     smartbotic_storage
@@ -257,10 +275,13 @@ target_link_libraries(smartbotic-runner PRIVATE
     smartbotic_config
     smartbotic_storage
     smartbotic_credentials
+    smartbotic_certs
     smartbotic_proto
     quickjs_lib
     CURL::libcurl
     gRPC::grpc++
+    OpenSSL::SSL
+    OpenSSL::Crypto
 )
 
 # Add MySQL client library if found

+ 187 - 0
lib/certs/pem_info.cpp

@@ -0,0 +1,187 @@
+#include "certs/pem_info.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <ctime>
+#include <memory>
+
+#include <openssl/bio.h>
+#include <openssl/err.h>
+#include <openssl/evp.h>
+#include <openssl/pem.h>
+#include <openssl/x509.h>
+#include <openssl/x509v3.h>
+
+namespace smartbotic::certs {
+
+using common::Error;
+using common::ErrorCode;
+using common::Result;
+
+namespace {
+
+std::string nameToString(X509_NAME* name) {
+    if (!name) {
+        return {};
+    }
+    // A BIO rather than X509_NAME_oneline: oneline mangles UTF-8 into escapes,
+    // and these strings are shown to a person.
+    std::unique_ptr<BIO, decltype(&BIO_free)> bio(BIO_new(BIO_s_mem()), BIO_free);
+    if (!bio) {
+        return {};
+    }
+    if (X509_NAME_print_ex(bio.get(), name, 0, XN_FLAG_RFC2253) < 0) {
+        return {};
+    }
+    char* data = nullptr;
+    const long len = BIO_get_mem_data(bio.get(), &data);
+    if (len <= 0 || !data) {
+        return {};
+    }
+    return std::string(data, static_cast<size_t>(len));
+}
+
+// ASN1 time to milliseconds since the epoch. Returns 0 when it cannot be read,
+// which the caller reports as "unknown" rather than as 1970.
+int64_t asn1TimeToMs(const ASN1_TIME* when) {
+    if (!when) {
+        return 0;
+    }
+    struct tm tm_value{};
+    if (ASN1_TIME_to_tm(when, &tm_value) != 1) {
+        return 0;
+    }
+    // timegm, not mktime: certificate times are UTC, and mktime would apply
+    // whatever timezone the server happens to be in.
+    const time_t seconds = timegm(&tm_value);
+    if (seconds == static_cast<time_t>(-1)) {
+        return 0;
+    }
+    return static_cast<int64_t>(seconds) * 1000;
+}
+
+std::string sha256Fingerprint(X509* cert) {
+    unsigned char digest[EVP_MAX_MD_SIZE];
+    unsigned int length = 0;
+    if (X509_digest(cert, EVP_sha256(), digest, &length) != 1) {
+        return {};
+    }
+    static const char* kHex = "0123456789ABCDEF";
+    std::string out;
+    out.reserve(length * 3);
+    for (unsigned int i = 0; i < length; ++i) {
+        if (i > 0) {
+            out.push_back(':');
+        }
+        out.push_back(kHex[digest[i] >> 4]);
+        out.push_back(kHex[digest[i] & 0x0F]);
+    }
+    return out;
+}
+
+std::string toLowerTrimmed(std::string value) {
+    // A trailing dot names the same host - "example.com." is the fully
+    // qualified spelling of "example.com" and must not miss a match.
+    while (!value.empty() && value.back() == '.') {
+        value.pop_back();
+    }
+    std::transform(value.begin(), value.end(), value.begin(),
+                   [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
+    return value;
+}
+
+} // namespace
+
+nlohmann::json CertificateInfo::toJson() const {
+    return {
+        {"subject", subject},
+        {"issuer", issuer},
+        {"notBefore", not_before_ms},
+        {"notAfter", not_after_ms},
+        {"fingerprintSha256", fingerprint_sha256},
+        {"isCa", is_ca},
+    };
+}
+
+Result<std::vector<CertificateInfo>> parsePem(const std::string& pem) {
+    if (pem.empty()) {
+        return Error(ErrorCode::InvalidArgument, "The certificate is empty");
+    }
+
+    std::unique_ptr<BIO, decltype(&BIO_free)> bio(
+        BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
+    if (!bio) {
+        return Error(ErrorCode::Internal, "Could not read the certificate");
+    }
+
+    std::vector<CertificateInfo> out;
+    while (true) {
+        X509* raw = PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr);
+        if (!raw) {
+            break;
+        }
+        std::unique_ptr<X509, decltype(&X509_free)> cert(raw, X509_free);
+
+        CertificateInfo info;
+        info.subject = nameToString(X509_get_subject_name(cert.get()));
+        info.issuer = nameToString(X509_get_issuer_name(cert.get()));
+        info.not_before_ms = asn1TimeToMs(X509_get0_notBefore(cert.get()));
+        info.not_after_ms = asn1TimeToMs(X509_get0_notAfter(cert.get()));
+        info.fingerprint_sha256 = sha256Fingerprint(cert.get());
+        info.is_ca = X509_check_ca(cert.get()) > 0;
+        out.push_back(std::move(info));
+    }
+
+    // Whatever PEM_read_bio_X509 stopped on is on the error stack. It is not
+    // reported: stopping is how the loop ends normally, at the end of the
+    // bundle. Leaving it there would poison the next OpenSSL call in this
+    // thread with an error it did not cause.
+    ERR_clear_error();
+
+    if (out.empty()) {
+        return Error(ErrorCode::InvalidArgument,
+                     "No certificate found. It has to be PEM, starting with "
+                     "-----BEGIN CERTIFICATE----- . A DER or PKCS#12 file has to be "
+                     "converted first, and a private key is not a certificate.");
+    }
+    return out;
+}
+
+bool hostMatches(const std::string& host, const std::vector<std::string>& patterns) {
+    const std::string needle = toLowerTrimmed(host);
+    if (needle.empty()) {
+        return false;
+    }
+
+    for (const auto& raw_pattern : patterns) {
+        const std::string pattern = toLowerTrimmed(raw_pattern);
+        if (pattern.empty()) {
+            continue;
+        }
+
+        if (pattern.rfind("*.", 0) == 0) {
+            // One leading label, and only one - the same rule certificate
+            // wildcards follow, so "*.fsociety.hu" covers api.fsociety.hu and
+            // neither fsociety.hu nor a.b.fsociety.hu.
+            const std::string suffix = pattern.substr(1);  // keeps the dot
+            if (needle.size() <= suffix.size()) {
+                continue;
+            }
+            if (needle.compare(needle.size() - suffix.size(), suffix.size(), suffix) != 0) {
+                continue;
+            }
+            const std::string label = needle.substr(0, needle.size() - suffix.size());
+            if (label.empty() || label.find('.') != std::string::npos) {
+                continue;
+            }
+            return true;
+        }
+
+        if (needle == pattern) {
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace smartbotic::certs

+ 53 - 0
lib/certs/pem_info.hpp

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <string>
+#include <vector>
+#include <nlohmann/json.hpp>
+
+#include "common/error.hpp"
+
+namespace smartbotic::certs {
+
+// What a stored certificate actually is, read out of the PEM rather than
+// typed in beside it. The API refuses a PEM it cannot parse, and the UI shows
+// these so an expiry is visible before it becomes a confusing TLS error at
+// three in the morning.
+struct CertificateInfo {
+    std::string subject;
+    std::string issuer;
+    int64_t not_before_ms = 0;
+    int64_t not_after_ms = 0;
+    // Uppercase hex, colon separated - the form openssl x509 -fingerprint
+    // prints, so it can be compared against a server by eye.
+    std::string fingerprint_sha256;
+    // A trust anchor is normally a CA. A leaf certificate can be pinned as an
+    // anchor too and OpenSSL accepts it, so this is reported rather than
+    // enforced.
+    bool is_ca = false;
+
+    nlohmann::json toJson() const;
+};
+
+// Every certificate in a PEM bundle, in file order. A bundle is allowed: an
+// internal CA is often issued under a root that has to travel with it.
+//
+// Failure means nothing in the input parsed as a certificate. The caller
+// refuses the input rather than storing it - a PEM that is silently kept and
+// silently ignored at request time is the shape of bug this whole feature
+// exists to avoid.
+common::Result<std::vector<CertificateInfo>> parsePem(const std::string& pem);
+
+// Whether a request's host is one this certificate was assigned to.
+//
+// Exact match, or a single leading "*." wildcard that matches exactly one
+// label - "*.fsociety.hu" covers "api.fsociety.hu" but not "fsociety.hu" and
+// not "a.b.fsociety.hu", which is how certificate wildcards themselves work.
+// Comparison is case-insensitive; a trailing dot on either side is ignored.
+//
+// This lives here, next to the parsing, because the webserver validates
+// patterns with it and the runner decides with it. Two implementations of one
+// matching rule is exactly how a host quietly ends up trusted in one place and
+// not the other.
+bool hostMatches(const std::string& host, const std::vector<std::string>& patterns);
+
+} // namespace smartbotic::certs

+ 133 - 2
src/runner/engine/script_engine.cpp

@@ -13,6 +13,11 @@
 #include <chrono>
 #include <thread>
 #include <curl/curl.h>
+#include <openssl/err.h>
+#include <openssl/pem.h>
+#include <openssl/ssl.h>
+#include <openssl/x509.h>
+#include "certs/pem_info.hpp"
 #include <sstream>
 #include <iomanip>
 #include <algorithm>
@@ -828,6 +833,77 @@ static nlohmann::json parseHeaders(const std::string& raw_headers) {
     return headers;
 }
 
+namespace {
+
+// What the SSL_CTX callback below needs, handed over through CURLOPT_SSL_CTX_DATA.
+struct TlsAnchorRequest {
+    const std::vector<TlsAnchor>* anchors = nullptr;
+    std::string host;
+};
+
+// The host a URL is actually for, asked of libcurl rather than parsed here.
+// A hand-rolled parser is how "user@host", a port, or an IPv6 literal in
+// brackets ends up matched against the wrong string.
+std::string hostOfUrl(const std::string& url) {
+    CURLU* handle = curl_url();
+    if (!handle) {
+        return {};
+    }
+    std::string out;
+    if (curl_url_set(handle, CURLUPART_URL, url.c_str(), 0) == CURLUE_OK) {
+        char* host = nullptr;
+        if (curl_url_get(handle, CURLUPART_HOST, &host, 0) == CURLUE_OK && host) {
+            out = host;
+            curl_free(host);
+        }
+    }
+    curl_url_cleanup(handle);
+    return out;
+}
+
+// Add this run's matching anchors to the trust store curl has already built.
+//
+// Adding, not replacing: CURLOPT_CAINFO_BLOB would swap out the public roots
+// for these, and every ordinary HTTPS call the workflow makes would then fail.
+// SSL_CTX_get_cert_store hands back the store curl has already loaded the
+// system bundle into, so what arrives here is "the usual roots" and what leaves
+// is "the usual roots plus these".
+CURLcode addTrustAnchors(CURL*, void* ssl_ctx, void* userdata) {
+    auto* request = static_cast<TlsAnchorRequest*>(userdata);
+    if (!request || !request->anchors) {
+        return CURLE_OK;
+    }
+    X509_STORE* store = SSL_CTX_get_cert_store(static_cast<SSL_CTX*>(ssl_ctx));
+    if (!store) {
+        return CURLE_OK;
+    }
+
+    for (const auto& anchor : *request->anchors) {
+        if (!certs::hostMatches(request->host, anchor.hosts)) {
+            continue;
+        }
+        BIO* bio = BIO_new_mem_buf(anchor.pem.data(), static_cast<int>(anchor.pem.size()));
+        if (!bio) {
+            continue;
+        }
+        int added = 0;
+        while (X509* cert = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr)) {
+            // A duplicate is not a failure - it means the same anchor is
+            // assigned twice, or is already a public root.
+            X509_STORE_add_cert(store, cert);
+            X509_free(cert);
+            ++added;
+        }
+        ERR_clear_error();
+        BIO_free(bio);
+        LOG_DEBUG("TLS: trusting {} certificate(s) from \"{}\" for host {}", added, anchor.name,
+                  request->host);
+    }
+    return CURLE_OK;
+}
+
+} // namespace
+
 // Perform HTTP request using curl
 static CurlResponse performHttpRequest(
     const std::string& method,
@@ -836,7 +912,9 @@ static CurlResponse performHttpRequest(
     const std::string& body,
     long timeout_ms,
     bool follow_redirects,
-    bool skip_tls_verify
+    bool skip_tls_verify,
+    const std::vector<TlsAnchor>& tls_anchors = {},
+    const std::optional<TlsClientIdentity>& tls_client_identity = std::nullopt
 ) {
     CurlResponse response;
 
@@ -863,6 +941,46 @@ static CurlResponse performHttpRequest(
     curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, skip_tls_verify ? 0L : 1L);
     curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
 
+    // Certificates this workflow was given to trust, applied only to the hosts
+    // they were assigned for. Verification stays fully on: these become
+    // additional trust anchors, so the chain still has to be valid and the
+    // hostname still has to match. That is the whole difference between this
+    // and skipTlsVerify above - one adds something to believe, the other stops
+    // checking.
+    //
+    // The struct has to outlive curl_easy_perform, so it lives here rather than
+    // inside an if.
+    TlsAnchorRequest anchor_request;
+    if (!tls_anchors.empty()) {
+        anchor_request.anchors = &tls_anchors;
+        anchor_request.host = hostOfUrl(url);
+        curl_easy_setopt(curl, CURLOPT_SSL_CTX_FUNCTION, addTrustAnchors);
+        curl_easy_setopt(curl, CURLOPT_SSL_CTX_DATA, &anchor_request);
+    }
+
+    // The identity this workflow presents when a server asks for one. Passed as
+    // a blob so the private key never reaches the filesystem.
+    curl_blob client_cert_blob{};
+    curl_blob client_key_blob{};
+    if (tls_client_identity.has_value()) {
+        client_cert_blob.data =
+            const_cast<char*>(tls_client_identity->certificate_pem.data());
+        client_cert_blob.len = tls_client_identity->certificate_pem.size();
+        client_cert_blob.flags = CURL_BLOB_COPY;
+        curl_easy_setopt(curl, CURLOPT_SSLCERT_BLOB, &client_cert_blob);
+        curl_easy_setopt(curl, CURLOPT_SSLCERTTYPE, "PEM");
+
+        client_key_blob.data = const_cast<char*>(tls_client_identity->private_key_pem.data());
+        client_key_blob.len = tls_client_identity->private_key_pem.size();
+        client_key_blob.flags = CURL_BLOB_COPY;
+        curl_easy_setopt(curl, CURLOPT_SSLKEY_BLOB, &client_key_blob);
+        curl_easy_setopt(curl, CURLOPT_SSLKEYTYPE, "PEM");
+
+        if (!tls_client_identity->passphrase.empty()) {
+            curl_easy_setopt(curl, CURLOPT_KEYPASSWD, tls_client_identity->passphrase.c_str());
+        }
+    }
+
     // Set method
     if (method == "POST") {
         curl_easy_setopt(curl, CURLOPT_POST, 1L);
@@ -1273,6 +1391,17 @@ static JSValue js_http_request(JSContext* ctx, JSValue this_val, int argc, JSVal
     }
     JS_FreeValue(ctx, retry_status_val);
 
+    // What this run trusts. Read straight off the context rather than through
+    // getScriptContext, which is defined further down this file. A null context
+    // means nothing was assigned, which is every workflow that has not been
+    // given a certificate - and then the request behaves exactly as before.
+    static const std::vector<TlsAnchor> kNoAnchors;
+    const auto* request_ctx = static_cast<const ScriptContext*>(JS_GetContextOpaque(ctx));
+    const std::vector<TlsAnchor>& request_anchors =
+        request_ctx ? request_ctx->tls_anchors : kNoAnchors;
+    const std::optional<TlsClientIdentity> request_identity =
+        request_ctx ? request_ctx->tls_client_identity : std::nullopt;
+
     // Perform the request
     try {
         CurlResponse response;
@@ -1282,7 +1411,9 @@ static JSValue js_http_request(JSContext* ctx, JSValue this_val, int argc, JSVal
         for (int attempt = 1; attempt <= attempts; ++attempt) {
             bool retryable = false;
             try {
-                response = performHttpRequest(method, url, headers, body, timeout_ms, follow_redirects, skip_tls_verify);
+                response = performHttpRequest(method, url, headers, body, timeout_ms,
+                                              follow_redirects, skip_tls_verify,
+                                              request_anchors, request_identity);
                 last_transport_error.clear();
                 retryable = isRetryableStatus(response.status_code, retry_statuses);
                 if (!retryable) {

+ 30 - 0
src/runner/engine/script_engine.hpp

@@ -1,5 +1,7 @@
 #pragma once
 
+#include <optional>
+
 #include <string>
 #include <map>
 #include <memory>
@@ -141,6 +143,28 @@ struct ScriptFileInfo {
 };
 
 // Script execution context
+// A certificate this run trusts, and the hosts it is trusted for.
+//
+// Resolved once when the execution starts, from the workflow's settings, and
+// carried to every HTTPS request the run makes. Host-bound on purpose: an
+// anchor added to the trust store vouches for whatever it is asked about, so
+// one assigned for an internal host must not also be able to vouch for
+// github.com.
+struct TlsAnchor {
+    std::string name;                  // for the log line, not for matching
+    std::string pem;                   // one certificate or a bundle
+    std::vector<std::string> hosts;    // exact, or a single leading *. label
+};
+
+// A client certificate this run presents when asked for one (mTLS). Unlike an
+// anchor this carries a private key, so it comes from the credential store
+// rather than the certificates collection and never touches disk.
+struct TlsClientIdentity {
+    std::string certificate_pem;
+    std::string private_key_pem;
+    std::string passphrase;
+};
+
 struct ScriptContext {
     std::string execution_id;
     std::string node_id;
@@ -180,6 +204,12 @@ struct ScriptContext {
 
     // PostgreSQL operations
     std::function<PostgresqlQueryResult(const PostgresqlQueryOptions&)> postgresql_query;
+
+    // What this run trusts, and what it presents. Empty for a workflow that
+    // assigned neither, which is every workflow until someone does - and then
+    // every HTTPS request behaves exactly as it did before.
+    std::vector<TlsAnchor> tls_anchors;
+    std::optional<TlsClientIdentity> tls_client_identity;
 };
 
 // Script execution result

+ 122 - 0
src/runner/workflow_engine.cpp

@@ -554,9 +554,35 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
 
     ++active_count_;
 
+    // Resolved once, here, rather than per request: every node in this run gets
+    // the same answer, and a certificate deleted mid-run cannot change what the
+    // run trusts halfway through.
+    //
+    // A bad reference ends the run before any node has done anything, which is
+    // the only point at which failing costs nothing.
     {
+        auto anchors = resolveTrustedCertificates(workflow);
+        if (anchors.failed()) {
+            result.status = ExecutionStatus::Failed;
+            result.error = anchors.error().message();
+            result.finished_at = TimeUtils::nowMs();
+            storeExecution(result);
+            --active_count_;
+            LOG_ERROR("Execution {} refused: {}", result.execution_id, result.error);
+            if (callback) {
+                callback("execution.failed", {
+                    {"executionId", result.execution_id},
+                    {"workflowId", workflow.id},
+                    {"error", result.error}
+                });
+            }
+            return result;
+        }
         std::lock_guard<std::mutex> lock(mutex_);
         active_executions_[result.execution_id] = result;
+        if (!anchors.value().empty()) {
+            execution_tls_anchors_[result.execution_id] = std::move(anchors.value());
+        }
     }
 
     if (callback) {
@@ -1093,6 +1119,7 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
     {
         std::lock_guard<std::mutex> lock(mutex_);
         active_executions_.erase(result.execution_id);
+        execution_tls_anchors_.erase(result.execution_id);
         cancelled_executions_.erase(result.execution_id);
     }
 
@@ -1455,6 +1482,89 @@ std::vector<std::string> WorkflowEngine::topologicalSort(
     return result;
 }
 
+common::Result<std::vector<engine::TlsAnchor>> WorkflowEngine::resolveTrustedCertificates(
+    const Workflow& workflow) {
+
+    std::vector<engine::TlsAnchor> anchors;
+    if (!workflow.settings.is_object() || !workflow.settings.contains("certificateIds")) {
+        return anchors;
+    }
+    const auto& ids = workflow.settings["certificateIds"];
+    if (!ids.is_array()) {
+        return anchors;
+    }
+
+    for (const auto& entry : ids) {
+        if (!entry.is_string()) {
+            continue;
+        }
+        const std::string id = entry.get<std::string>();
+        if (id.empty()) {
+            continue;
+        }
+
+        auto stored = storage_.get("certificates", id);
+        if (stored.failed()) {
+            // Refused, not skipped. Running on would send the request with the
+            // public roots only, and the TLS failure that followed would look
+            // like a problem with the server rather than with a certificate
+            // somebody deleted.
+            return common::Error(common::ErrorCode::NotFound,
+                                 "This workflow trusts certificate " + id +
+                                     ", which no longer exists. Reassign or remove it in the "
+                                     "workflow's settings.");
+        }
+
+        const std::string cert_project = stored.value().value("projectId", "");
+        if (cert_project != workflow.project_id) {
+            // A certificate is a decision made inside one project. Honouring a
+            // reference from another would let a workflow borrow trust from a
+            // project its author cannot see.
+            return common::Error(common::ErrorCode::PermissionDenied,
+                                 "Certificate " + id + " belongs to another project and cannot "
+                                 "be used by this workflow.");
+        }
+
+        engine::TlsAnchor anchor;
+        anchor.name = stored.value().value("name", id);
+        anchor.pem = stored.value().value("pem", "");
+        if (stored.value().contains("hosts") && stored.value()["hosts"].is_array()) {
+            for (const auto& host : stored.value()["hosts"]) {
+                if (host.is_string()) {
+                    anchor.hosts.push_back(host.get<std::string>());
+                }
+            }
+        }
+        if (anchor.pem.empty() || anchor.hosts.empty()) {
+            return common::Error(common::ErrorCode::InvalidArgument,
+                                 "Certificate " + anchor.name +
+                                     " has no certificate data or no hosts, so it could never "
+                                     "apply to a request.");
+        }
+
+        // An expired anchor is not refused - a chain can still be valid under a
+        // root that has expired in some configurations, and refusing here would
+        // take a workflow down for a reason the request itself may not care
+        // about. It is worth saying out loud, though, because it is the most
+        // likely explanation for a TLS failure that appears from nowhere.
+        const int64_t not_after = stored.value().value("notAfter", static_cast<int64_t>(0));
+        if (not_after > 0 && not_after < TimeUtils::nowMs()) {
+            LOG_WARN("Workflow {} trusts certificate \"{}\", which expired on {} - a request to "
+                     "{} may fail its certificate check",
+                     workflow.id, anchor.name, not_after,
+                     anchor.hosts.empty() ? "its host" : anchor.hosts.front());
+        }
+
+        anchors.push_back(std::move(anchor));
+    }
+
+    if (!anchors.empty()) {
+        LOG_INFO("Workflow {} trusts {} certificate(s) for named hosts", workflow.id,
+                 anchors.size());
+    }
+    return anchors;
+}
+
 NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
                                                 const nlohmann::json& input,
                                                 const std::string& execution_id,
@@ -1561,6 +1671,18 @@ NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
     ctx.workflow_id = workflow.id;
     ctx.input = input;
     ctx.config = node.config;
+
+    // What this run was told to trust, looked up by execution rather than held
+    // on the engine: one engine serves concurrent runs, and one run's trust
+    // must never reach another's request.
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        auto anchors = execution_tls_anchors_.find(execution_id);
+        if (anchors != execution_tls_anchors_.end()) {
+            ctx.tls_anchors = anchors->second;
+        }
+    }
+
     ctx.log_handler = [&node](const std::string& level, const std::string& msg) {
         if (level == "error") {
             LOG_ERROR("[Node {}] {}", node.id, msg);

+ 17 - 0
src/runner/workflow_engine.hpp

@@ -334,6 +334,16 @@ private:
     // expressions), pass it here so executeNode reuses it instead of
     // looking it up - and copying its JavaScript source - a second time.
     // Left empty, executeNode looks it up itself.
+    // The certificates a workflow says its runs may trust, read from
+    // settings.certificateIds.
+    //
+    // Fails the run rather than dropping one: a missing certificate, or one
+    // belonging to another project, means the run would go out with less trust
+    // than its author asked for, and a request that then fails its TLS check
+    // would point at the wrong thing entirely.
+    common::Result<std::vector<engine::TlsAnchor>> resolveTrustedCertificates(
+        const Workflow& workflow);
+
     NodeExecutionResult executeNode(const WorkflowNode& node,
                                    const nlohmann::json& input,
                                    const std::string& execution_id,
@@ -523,6 +533,13 @@ private:
     PostgresqlQueryCallback postgresql_query_callback_;
 
     std::unordered_map<std::string, ExecutionResult> active_executions_;
+
+    // What each running execution trusts, resolved once when it starts from
+    // the workflow's settings.certificateIds and read by every node that makes
+    // an HTTPS request. Keyed by execution id and cleared with the rest of a
+    // run's state, because the engine is shared by concurrent executions and
+    // one run's trust must never leak into another's.
+    std::unordered_map<std::string, std::vector<engine::TlsAnchor>> execution_tls_anchors_;
     std::unordered_set<std::string> cancelled_executions_;
 
     // Which executions were started by which. A workflow called as a step runs

+ 369 - 0
src/webserver/api/certificate_controller.cpp

@@ -0,0 +1,369 @@
+#include "certificate_controller.hpp"
+
+#include <algorithm>
+
+#include "certs/pem_info.hpp"
+#include "common/uuid.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::webserver::api {
+
+CertificateController::CertificateController(storage::StorageClient& storage,
+                                             auth::AccessControl& access,
+                                             auth::AuthMiddleware& middleware)
+    : storage_(storage), access_(access), middleware_(middleware) {}
+
+void CertificateController::registerRoutes(httplib::Server& server) {
+    server.Get("/api/v1/certificates", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            listCertificates(req, res, ctx);
+        });
+    });
+
+    server.Post("/api/v1/certificates", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            createCertificate(req, res, ctx);
+        });
+    });
+
+    server.Get(R"(/api/v1/certificates/([^/]+))",
+               [this](const httplib::Request& req, httplib::Response& res) {
+                   middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+                       getCertificate(req, res, ctx);
+                   });
+               });
+
+    server.Put(R"(/api/v1/certificates/([^/]+))",
+               [this](const httplib::Request& req, httplib::Response& res) {
+                   middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+                       updateCertificate(req, res, ctx);
+                   });
+               });
+
+    server.Delete(R"(/api/v1/certificates/([^/]+))",
+                  [this](const httplib::Request& req, httplib::Response& res) {
+                      middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+                          deleteCertificate(req, res, ctx);
+                      });
+                  });
+}
+
+bool CertificateController::buildDocument(httplib::Response& res, const nlohmann::json& body,
+                                          nlohmann::json& doc) {
+    const std::string name = body.value("name", "");
+    if (name.empty()) {
+        sendError(res, "A name is required, so this can be told apart in a workflow's settings", 400);
+        return false;
+    }
+
+    const std::string pem = body.value("pem", "");
+    auto parsed = certs::parsePem(pem);
+    if (parsed.failed()) {
+        // Refused rather than stored. A certificate that cannot be parsed
+        // cannot be applied either, and storing it would leave a workflow
+        // trusting something that silently does nothing at request time.
+        sendError(res, parsed.error().message(), 400);
+        return false;
+    }
+
+    if (!body.contains("hosts") || !body["hosts"].is_array() || body["hosts"].empty()) {
+        sendError(res,
+                  "Name at least one host this certificate is for, such as "
+                  "internal.example.com or *.example.com. A certificate with no hosts would "
+                  "never be applied to anything.",
+                  400);
+        return false;
+    }
+
+    std::vector<std::string> hosts;
+    for (const auto& entry : body["hosts"]) {
+        if (!entry.is_string()) {
+            sendError(res, "Each host has to be a string", 400);
+            return false;
+        }
+        std::string host = entry.get<std::string>();
+        // Trimmed here rather than at match time: a stored " example.com" would
+        // never match anything and the reason would not be visible on screen.
+        const auto first = host.find_first_not_of(" \t\r\n");
+        const auto last = host.find_last_not_of(" \t\r\n");
+        if (first == std::string::npos) {
+            continue;
+        }
+        host = host.substr(first, last - first + 1);
+
+        // A pattern with a wildcard anywhere but a single leading label does
+        // not mean what whoever typed it thinks it means, so it is refused
+        // rather than quietly never matching.
+        const auto star = host.find('*');
+        if (star != std::string::npos && host.rfind("*.", 0) != 0) {
+            sendError(res,
+                      "\"" + host + "\" is not a host pattern this understands. A wildcard is "
+                      "allowed only as a single leading label, like *.example.com.",
+                      400);
+            return false;
+        }
+        if (host == "*" || host == "*.") {
+            sendError(res, "A bare * would trust this certificate for every host, which "
+                           "defeats naming hosts at all", 400);
+            return false;
+        }
+        hosts.push_back(host);
+    }
+    if (hosts.empty()) {
+        sendError(res, "Name at least one host this certificate is for", 400);
+        return false;
+    }
+
+    doc["name"] = name;
+    doc["pem"] = pem;
+    doc["hosts"] = hosts;
+    doc["description"] = body.value("description", "");
+
+    // Parsed once, on write. A listing that had to parse every PEM to show an
+    // expiry would parse them all on every page.
+    nlohmann::json certificates = nlohmann::json::array();
+    for (const auto& info : parsed.value()) {
+        certificates.push_back(info.toJson());
+    }
+    doc["certificates"] = certificates;
+    // The leaf-most entry is what a reader means by "this certificate", and it
+    // is the one whose expiry matters first.
+    doc["subject"] = parsed.value().front().subject;
+    doc["issuer"] = parsed.value().front().issuer;
+    doc["notBefore"] = parsed.value().front().not_before_ms;
+    doc["notAfter"] = parsed.value().front().not_after_ms;
+    doc["fingerprintSha256"] = parsed.value().front().fingerprint_sha256;
+    doc["isCa"] = parsed.value().front().is_ca;
+    return true;
+}
+
+void CertificateController::listCertificates(const httplib::Request& req, httplib::Response& res,
+                                             const auth::AuthContext& ctx) {
+    storage::QueryOptions options;
+    options.page_size = 500;
+    if (req.has_param("projectId")) {
+        options.filters.push_back({"projectId", req.get_param_value("projectId")});
+    }
+
+    auto result = storage_.query(kCollection, options);
+    if (result.failed()) {
+        // A collection that does not exist yet is an empty list, not an error -
+        // this is the first request on a fresh install.
+        sendJson(res, {{"certificates", nlohmann::json::array()}, {"total", 0}});
+        return;
+    }
+
+    const auto usage = certificateUsage();
+
+    nlohmann::json visible = nlohmann::json::array();
+    for (auto doc : result.value().documents) {
+        if (!access_.allowed(ctx, auth::AccessControl::projectOf(doc), auth::Action::Read)) {
+            continue;
+        }
+        doc["usedByWorkflows"] = usage.count(doc.value("_id", "")) ? usage.at(doc.value("_id", "")) : 0;
+        visible.push_back(doc);
+    }
+
+    sendJson(res, {{"certificates", visible}, {"total", visible.size()}});
+}
+
+void CertificateController::getCertificate(const httplib::Request& req, httplib::Response& res,
+                                           const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1].str();
+    auto found = storage_.get(kCollection, id);
+    if (found.failed()) {
+        sendError(res, "Certificate not found", 404);
+        return;
+    }
+    // 404 rather than 403 for somebody who cannot reach the project at all,
+    // matching every other listing here: whether a thing exists is only told to
+    // people who can see it.
+    if (!access_.allowed(ctx, auth::AccessControl::projectOf(found.value()), auth::Action::Read)) {
+        sendError(res, "Certificate not found", 404);
+        return;
+    }
+    sendJson(res, found.value());
+}
+
+void CertificateController::createCertificate(const httplib::Request& req, httplib::Response& res,
+                                              const auth::AuthContext& ctx) {
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const std::exception&) {
+        sendError(res, "Invalid request body: not valid JSON", 400);
+        return;
+    }
+
+    const std::string project_id = body.value("projectId", "");
+    if (project_id.empty()) {
+        sendError(res, "A projectId is required - a certificate belongs to a project", 400);
+        return;
+    }
+    if (!access_.allowed(ctx, project_id, auth::Action::Write)) {
+        sendError(res, "You cannot add a certificate to that project", 403);
+        return;
+    }
+
+    nlohmann::json doc;
+    if (!buildDocument(res, body, doc)) {
+        return;
+    }
+    doc["projectId"] = project_id;
+    doc["createdBy"] = ctx.user_id;
+    doc["ownerId"] = ctx.user_id;
+
+    const std::string id = common::UUID::generatePrefixed("cert");
+    auto result = storage_.insert(kCollection, doc, id);
+    if (result.failed()) {
+        sendError(res, result.error().message(), 500);
+        return;
+    }
+
+    LOG_INFO("Certificate {} ({}) created in project {} by {}", id, doc.value("name", ""),
+             project_id, ctx.username);
+
+    auto stored = storage_.get(kCollection, id);
+    sendJson(res, stored.ok() ? stored.value() : doc, 201);
+}
+
+void CertificateController::updateCertificate(const httplib::Request& req, httplib::Response& res,
+                                              const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1].str();
+    auto existing = storage_.get(kCollection, id);
+    if (existing.failed()) {
+        sendError(res, "Certificate not found", 404);
+        return;
+    }
+    const std::string project_id = auth::AccessControl::projectOf(existing.value());
+    if (!access_.allowed(ctx, project_id, auth::Action::Read)) {
+        sendError(res, "Certificate not found", 404);
+        return;
+    }
+    if (!access_.allowed(ctx, project_id, auth::Action::Write)) {
+        sendError(res, "You cannot change this certificate", 403);
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const std::exception&) {
+        sendError(res, "Invalid request body: not valid JSON", 400);
+        return;
+    }
+
+    // Whatever the body leaves out keeps its stored value, so a caller that
+    // only wants to rename it does not have to send the PEM back.
+    if (!body.contains("pem")) body["pem"] = existing.value().value("pem", "");
+    if (!body.contains("hosts")) body["hosts"] = existing.value().value("hosts", nlohmann::json::array());
+    if (!body.contains("name")) body["name"] = existing.value().value("name", "");
+    if (!body.contains("description")) body["description"] = existing.value().value("description", "");
+
+    nlohmann::json doc;
+    if (!buildDocument(res, body, doc)) {
+        return;
+    }
+    // The project a certificate belongs to is not moved by an edit. Moving it
+    // would change who can use it, which is a different act and would need the
+    // target project checked as well.
+    doc["projectId"] = project_id;
+    doc["createdBy"] = existing.value().value("createdBy", "");
+    doc["ownerId"] = existing.value().value("ownerId", existing.value().value("createdBy", ""));
+
+    auto result = storage_.update(kCollection, id, doc);
+    if (result.failed()) {
+        sendError(res, result.error().message(), 500);
+        return;
+    }
+
+    LOG_INFO("Certificate {} updated by {}", id, ctx.username);
+    auto stored = storage_.get(kCollection, id);
+    sendJson(res, stored.ok() ? stored.value() : doc);
+}
+
+void CertificateController::deleteCertificate(const httplib::Request& req, httplib::Response& res,
+                                              const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1].str();
+    auto existing = storage_.get(kCollection, id);
+    if (existing.failed()) {
+        sendError(res, "Certificate not found", 404);
+        return;
+    }
+    const std::string project_id = auth::AccessControl::projectOf(existing.value());
+    if (!access_.allowed(ctx, project_id, auth::Action::Read)) {
+        sendError(res, "Certificate not found", 404);
+        return;
+    }
+    if (!access_.allowed(ctx, project_id, auth::Action::Manage)) {
+        sendError(res, "You cannot delete this certificate", 403);
+        return;
+    }
+
+    // A workflow that names a deleted certificate refuses to run rather than
+    // running with less trust than its author asked for, so deleting one in use
+    // breaks those runs. Said plainly here, before it happens, rather than
+    // discovered at three in the morning.
+    const auto usage = certificateUsage();
+    const auto in_use = usage.find(id);
+    if (in_use != usage.end() && in_use->second > 0 &&
+        req.get_param_value("force") != "true") {
+        sendError(res,
+                  "This certificate is assigned to " + std::to_string(in_use->second) +
+                      " workflow(s), which will refuse to run without it. Remove it from them "
+                      "first, or repeat this with ?force=true.",
+                  409);
+        return;
+    }
+
+    auto result = storage_.remove(kCollection, id);
+    if (result.failed()) {
+        sendError(res, result.error().message(), 500);
+        return;
+    }
+
+    LOG_INFO("Certificate {} ({}) deleted by {}", id, existing.value().value("name", ""),
+             ctx.username);
+    sendJson(res, {{"success", true}});
+}
+
+std::unordered_map<std::string, int> CertificateController::certificateUsage() {
+    std::unordered_map<std::string, int> usage;
+
+    storage::QueryOptions options;
+    options.page_size = 1000;
+    options.fields = {"settings"};
+    auto workflows = storage_.query("workflows", options);
+    if (workflows.failed()) {
+        return usage;
+    }
+
+    for (const auto& wf : workflows.value().documents) {
+        if (!wf.contains("settings") || !wf["settings"].is_object()) {
+            continue;
+        }
+        const auto& settings = wf["settings"];
+        if (settings.contains("certificateIds") && settings["certificateIds"].is_array()) {
+            for (const auto& entry : settings["certificateIds"]) {
+                if (entry.is_string()) {
+                    ++usage[entry.get<std::string>()];
+                }
+            }
+        }
+    }
+    return usage;
+}
+
+void CertificateController::sendJson(httplib::Response& res, const nlohmann::json& data,
+                                     int status) {
+    res.status = status;
+    res.set_content(data.dump(), "application/json");
+}
+
+void CertificateController::sendError(httplib::Response& res, const std::string& message,
+                                      int status) {
+    res.status = status;
+    res.set_content(nlohmann::json{{"error", message}}.dump(), "application/json");
+}
+
+} // namespace smartbotic::webserver::api

+ 73 - 0
src/webserver/api/certificate_controller.hpp

@@ -0,0 +1,73 @@
+#pragma once
+
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "../auth/access.hpp"
+#include "../auth/auth_middleware.hpp"
+#include "storage/storage_client.hpp"
+
+namespace smartbotic::webserver::api {
+
+// Certificates a workflow can trust, so a self-signed or privately-issued host
+// is reachable without turning verification off.
+//
+// A certificate is public - it is what a server hands to anyone who connects -
+// so unlike a credential there is no secret here and nothing is encrypted. What
+// it does need is the same project scoping, because trusting an anchor is a
+// decision about what a project's workflows will believe.
+//
+// Deliberately not a credential type: a credential is something to keep, this
+// is something to publish, and mixing them would put a public blob behind the
+// encryption path and the "never return the secret" rules that exist for real
+// secrets. The mTLS identity - which does hold a private key - IS a credential
+// type, for exactly the same reason in reverse.
+class CertificateController {
+public:
+    CertificateController(storage::StorageClient& storage, auth::AccessControl& access,
+                          auth::AuthMiddleware& middleware);
+
+    void registerRoutes(httplib::Server& server);
+
+    // The collection every certificate lives in. Public, so it is named here
+    // rather than hidden - the runner reads it directly.
+    static constexpr const char* kCollection = "certificates";
+
+private:
+    void listCertificates(const httplib::Request& req, httplib::Response& res,
+                          const auth::AuthContext& ctx);
+    void getCertificate(const httplib::Request& req, httplib::Response& res,
+                        const auth::AuthContext& ctx);
+    void createCertificate(const httplib::Request& req, httplib::Response& res,
+                           const auth::AuthContext& ctx);
+    void updateCertificate(const httplib::Request& req, httplib::Response& res,
+                           const auth::AuthContext& ctx);
+    void deleteCertificate(const httplib::Request& req, httplib::Response& res,
+                           const auth::AuthContext& ctx);
+
+    // Reads name, hosts and pem from a request body into a document, parsing
+    // the PEM and refusing anything that is not one. Writes the error response
+    // itself and returns false when the body cannot be used.
+    //
+    // The parsed fields are stored beside the PEM rather than computed on every
+    // read: a listing that had to parse every certificate to show an expiry
+    // would parse them all on every page.
+    bool buildDocument(httplib::Response& res, const nlohmann::json& body,
+                       nlohmann::json& doc);
+
+    // How many workflows name each certificate, keyed by id. A certificate in
+    // use should not be deleted without the person knowing what breaks.
+    std::unordered_map<std::string, int> certificateUsage();
+
+    void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
+    void sendError(httplib::Response& res, const std::string& message, int status);
+
+    storage::StorageClient& storage_;
+    auth::AccessControl& access_;
+    auth::AuthMiddleware& middleware_;
+};
+
+} // namespace smartbotic::webserver::api

+ 5 - 0
src/webserver/webserver_service.cpp

@@ -12,6 +12,7 @@
 #include "api/file_controller.hpp"
 #include "api/proxy_controller.hpp"
 #include "api/credential_controller.hpp"
+#include "api/certificate_controller.hpp"
 #include "api/settings_controller.hpp"
 #include "nodes/node_store.hpp"
 #include "grpc/node_sync_service.hpp"
@@ -487,6 +488,10 @@ void WebServerService::setupRoutes() {
         *credential_store_, *access_, *storage_, *auth_middleware_);
     credential_ctrl_->registerRoutes(server);
 
+    certificate_ctrl_ = std::make_unique<api::CertificateController>(
+        *storage_, *access_, *auth_middleware_);
+    certificate_ctrl_->registerRoutes(server);
+
     settings_ctrl_ = std::make_unique<api::SettingsController>(
         *settings_store_, *auth_middleware_, config_);
     settings_ctrl_->registerRoutes(server);

+ 2 - 0
src/webserver/webserver_service.hpp

@@ -46,6 +46,7 @@ namespace smartbotic::webserver::api {
     class WebhookController;
     class DatabaseController;
     class CredentialController;
+    class CertificateController;
     class SettingsController;
 }
 
@@ -185,6 +186,7 @@ private:
     std::unique_ptr<api::WebhookController> webhook_ctrl_;
     std::unique_ptr<api::DatabaseController> database_ctrl_;
     std::unique_ptr<api::CredentialController> credential_ctrl_;
+    std::unique_ptr<api::CertificateController> certificate_ctrl_;
     std::unique_ptr<api::SettingsController> settings_ctrl_;
 };