diff --git a/.github/workflows/ci-scripts-build.yml b/.github/workflows/ci-scripts-build.yml index a4f09dbe3..4706b89f8 100644 --- a/.github/workflows/ci-scripts-build.yml +++ b/.github/workflows/ci-scripts-build.yml @@ -151,8 +151,10 @@ jobs: - name: "apt-get install" run: | sudo apt-get update - sudo apt-get -y install g++-mingw-w64-x86-64 cmake gdb qemu-system-x86 + sudo apt-get -y install g++-mingw-w64-x86-64 cmake gdb qemu-system-x86 libssl-dev if: runner.os == 'Linux' + - name: Host Info + run: openssl version -a - name: Automatic core dumper analysis uses: mdavidsaver/ci-core-dumper@master - name: Prepare and compile dependencies diff --git a/.gitignore b/.gitignore index 3b2a40b95..a630a3a40 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,49 @@ __pycache__/ *.orig *.log .*.swp -.vscode \ No newline at end of file +.vscode +.clang-format +.iocsh_history +EPICS_CA.pem +login_certs.txt +system_certs.txt +.idea/editor.xml +.idea/misc.xml +.idea/modules.xml +.idea/pvxs.iml +.idea/vcs.xml +.idea/workspace.xml +.idea/codeStyles/codeStyleConfig.xml +.idea/copyright/profiles_settings.xml +.idea/inspectionProfiles/Project_Default.xml +.idea/shelf/Add_detailed_documentation_for_certificate_testing_code__Enhanced_the__testtlswithcmsandst.xml +.idea/shelf/authenticator_framework.xml +.idea/shelf/big_refactor.xml +.idea/shelf/Changes.xml +.idea/shelf/Don_t_kn.xml +.idea/shelf/framework_updates.xml +.idea/shelf/Kerberos_spike.xml +.idea/shelf/move_lambdas_into_subscribe_and_watch_methods.xml +.idea/shelf/rename_CertificateStatus_and_change_OCSPStatus_creation_and_verification.xml +.idea/shelf/statuslistener.xml +.idea/shelf/subdirectories_for_each_auth_method1.xml +.idea/shelf/Add_detailed_documentation_for_certificate_testing_code__Enhanced_the_`testtlswithcmsandst/shelved.patch +.idea/shelf/authenticator_framework/shelved.patch +.idea/shelf/big_refactor/shelved.patch +.idea/shelf/Changes/shelved.patch +.idea/shelf/Don't_kn/shelved.patch +.idea/shelf/framework_updates/shelved.patch +.idea/shelf/Kerberos_spike/shelved.patch +.idea/shelf/move_lambdas_into_subscribe_and_watch_methods/shelved.patch +.idea/shelf/rename_CertificateStatus_and_change_OCSPStatus_creation_and_verification/shelved.patch +.idea/shelf/statuslistener/shelved.patch +.idea/shelf/subdirectories_for_each_auth_method1/shelved.patch +configure/TOOLCHAIN.tmp +test/testioc.db +test/testiocg.db +test/slac-test/Sign In.htm +test/slac-test/Sign In.mhtml +test/slac-test/Sign In_files/doe-logo.png +test/slac-test/Sign In_files/logo.png +test/slac-test/Sign In_files/stanford-logo.png +test/slac-test/Sign In_files/style.css diff --git a/.gitmodules b/.gitmodules index 6ce0698fa..a430f4631 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "bundle/libevent"] path = bundle/libevent url = https://github.com/libevent/libevent +[submodule "bundle/jwt-cpp"] + path = bundle/jwt-cpp + url = https://github.com/Thalhammer/jwt-cpp.git +[submodule "bundle/CLI11"] + path = bundle/CLI11 + url = https://github.com/CLIUtils/CLI11.git diff --git a/.idea/jsonSchemas.xml b/.idea/jsonSchemas.xml new file mode 100644 index 000000000..e135c6f0e --- /dev/null +++ b/.idea/jsonSchemas.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index a1074ed6b..db8a90a8e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include LICENSE include README.md include configure/CONFIG_PVXS_VERSION +include configure/probe-openssl.c include src/*.h include src/*.h@ include src/*.cpp diff --git a/Makefile b/Makefile index 5def74da9..739cea7c2 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,11 @@ include $(TOP)/configure/CONFIG # Directories to build, any order DIRS += configure +DIRS += setup +setup_DEPEND_DIRS = configure + DIRS += src -src_DEPEND_DIRS = configure +src_DEPEND_DIRS = setup DIRS += tools tools_DEPEND_DIRS = src @@ -22,6 +25,9 @@ endif DIRS += test test_DEPEND_DIRS = src ioc +DIRS += certs +certs_DEPEND_DIRS = src + DIRS += example example_DEPEND_DIRS = src diff --git a/bundle/CLI11 b/bundle/CLI11 new file mode 160000 index 000000000..063b2c911 --- /dev/null +++ b/bundle/CLI11 @@ -0,0 +1 @@ +Subproject commit 063b2c911cea8bd4b908d6187c6a007ad320cb1a diff --git a/bundle/Makefile b/bundle/Makefile index f59cd8e8c..f404c425c 100644 --- a/bundle/Makefile +++ b/bundle/Makefile @@ -1,5 +1,7 @@ TOP=.. +_PVXS_BOOTSTRAP = YES + include $(TOP)/configure/CONFIG CMAKE ?= cmake @@ -21,7 +23,6 @@ LIBEVENT_$(T_A) = $(INSTALL_LOCATION)/bundle/usr/$(T_A) CMAKEFLAGS += -DCMAKE_INSTALL_PREFIX:PATH="$(abspath $(LIBEVENT_$(T_A)))" # not needed, and may not be available on embedded targets, so never try -CMAKEFLAGS += -DEVENT__DISABLE_OPENSSL=ON CMAKEFLAGS += -DEVENT__DISABLE_MBEDTLS=ON # not run, so why bother? diff --git a/bundle/jwt-cpp b/bundle/jwt-cpp new file mode 160000 index 000000000..a6927cb81 --- /dev/null +++ b/bundle/jwt-cpp @@ -0,0 +1 @@ +Subproject commit a6927cb8140858c34e05d1a954626b9849fbcdfc diff --git a/bundle/libevent b/bundle/libevent index 1fe626c4d..90b9520f3 160000 --- a/bundle/libevent +++ b/bundle/libevent @@ -1 +1 @@ -Subproject commit 1fe626c4da14fef6cf45b95e48a438e0f93a499e +Subproject commit 90b9520f3ca04dd1278c831e61a82859e3be090e diff --git a/certs/Makefile b/certs/Makefile new file mode 100644 index 000000000..1dd879ac7 --- /dev/null +++ b/certs/Makefile @@ -0,0 +1,55 @@ +TOP=.. + +include $(TOP)/configure/CONFIG +# cfg/ sometimes isn't correctly included due to a Base bug +# so we do here (maybe again) as workaround +-include $(wildcard $(TOP)/cfg/CONFIG*) +#---------------------------------------- +# ADD MACRO DEFINITIONS AFTER THIS LINE +#============================= + +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL -I$(TOP)/bundle/CLI11/include +AUTHN = $(TOP)/certs/authn +SRC_DIRS += $(TOP)/src +SRC_DIRS += $(TOP)/ioc +SRCS += p12filefactory.cpp +SRCS += pemfilefactory.cpp +SRCS += certfilefactory.cpp +SRCS += certfactory.cpp + +PROD_LIBS = pvxs Com + +# access to API and private headers +USR_CPPFLAGS += -I$(TOP)/src/pvxs +USR_CPPFLAGS += -I$(TOP)/src +USR_CPPFLAGS += -I$(TOP)/ioc + +#INC += certstatus.h +INC += security.h + +PROD += pvacms +pvacms_SRCS += pvacms.cpp +pvacms_SRCS += configcms.cpp +pvacms_SRCS += certstatus.cpp +pvacms_SRCS += certstatusfactory.cpp +pvacms_SRCS += credentials.cpp +pvacms_SRCS += securityclient.cpp + +pvacms_LIBS += $(EPICS_BASE_IOC_LIBS) +pvacms_SYS_LIBS += sqlite3 ssl crypto + +#PROD += ocsppva +#pvaocsp_SRCS += ocsppva.cpp +#pvaocsp_SRCS += configocsp.cpp + +include $(AUTHN)/Makefile + +endif # EVENT2_HAS_OPENSSL + +#=========================== + +include $(TOP)/configure/RULES +-include $(wildcard $(TOP)/cfg/RULES*) +#---------------------------------------- +# ADD RULES AFTER THIS LINE diff --git a/certs/authn/Makefile b/certs/authn/Makefile new file mode 100644 index 000000000..d766f3852 --- /dev/null +++ b/certs/authn/Makefile @@ -0,0 +1,23 @@ +# This is a Makefile fragment, see cert/Makefile. + +SRC_DIRS += $(AUTHN) + +#-------------------------------------------- +# ADD AUTHENTICATION PLUGINS AFTER THIS LINE + +SRCS += auth.cpp +SRCS += ccrmanager.cpp + +include $(AUTHN)/std/Makefile + +ifeq ($(PVXS_ENABLE_JWT_AUTH),YES) +include $(AUTHN)/jwt/Makefile +endif + +ifeq ($(PVXS_ENABLE_KRB_AUTH),YES) +include $(AUTHN)/krb/Makefile +endif + +ifeq ($(PVXS_ENABLE_LDAP_AUTH),YES) +include $(AUTHN)/ldap/Makefile +endif diff --git a/certs/authn/auth.cpp b/certs/authn/auth.cpp new file mode 100644 index 000000000..d5a918eb6 --- /dev/null +++ b/certs/authn/auth.cpp @@ -0,0 +1,93 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "auth.h" + +#include +#include +#include + +#include + +#include "ccrmanager.h" +#include "certfactory.h" +#include "ownedptr.h" +#include "p12filefactory.h" +#include "security.h" + +namespace pvxs { +namespace certs { + +/** + * @brief Creates a signed certificate. + * + * Create a PVStructure that corresponds to the ccr parameter of a certificate + * creation request. This request will be sent to the PVACMS through the default + * channel (PVAccess) and will be used to create the certificate. + * + * @param credentials the credentials that describe the subject of the + * certificate + * @param key_pair the public/private key to be used in the certificate, only + * public key is used + * @param usage the desired certificate usage + * @return A managed shared CertCreationRequest object. + */ +std::shared_ptr Auth::createCertCreationRequest(const std::shared_ptr &credentials, const std::shared_ptr &key_pair, + const uint16_t &usage) const { + // Create a new CertCreationRequest object. + auto cert_creation_request = std::make_shared(type_, verifier_fields_); + cert_creation_request->credentials = credentials; + + // Fill in the ccr from the base data we've gathered so far. + cert_creation_request->ccr["name"] = credentials->name; + cert_creation_request->ccr["country"] = credentials->country; + cert_creation_request->ccr["organization"] = credentials->organization; + cert_creation_request->ccr["organization_unit"] = credentials->organization_unit; + cert_creation_request->ccr["type"] = type_; + cert_creation_request->ccr["usage"] = usage; + cert_creation_request->ccr["not_before"] = credentials->not_before; + cert_creation_request->ccr["not_after"] = credentials->not_after; + cert_creation_request->ccr["pub_key"] = key_pair->public_key; + return cert_creation_request; +} + +/** + * @brief Signs a certificate. + * + * This function takes a certificate creation request and sends its ccr + * PVStructure to PVACMS to be signed. It will wait for the signed signature or + * any reported error. + * + * @param cert_creation_request A shared pointer to a CertCreationRequest object + * containing the ccr PVStructure which contains the certificate, and its + * validity as well as any verifier specific required fields. + * @return the signed certificate + * @throws std::runtime_error when exceptions arise + * + * @note It is the responsibility of the caller to ensure that the + * cert_creation_request object is valid and contains the required information + * before calling this function. + */ +std::string Auth::processCertificateCreationRequest(const std::shared_ptr &cert_creation_request) const { + // Forward the ccr to the certificate management service + std::string p12_pem_string(ccr_manager_.createCertificate(cert_creation_request)); + return p12_pem_string; +} + +std::shared_ptr Auth::createKeyPair(const ConfigCommon &config) { + // Create a key pair + const auto key_pair(IdFileFactory::createKeyPair()); + + // Create private key file containing private key + if ( config.tls_private_key_filename.empty()) { + IdFileFactory::create(config.tls_cert_filename, config.tls_cert_password, key_pair)->writeIdentityFile(); + } else { + IdFileFactory::create(config.tls_private_key_filename, config.tls_private_key_password, key_pair)->writeIdentityFile(); + } + return key_pair; +} +} // namespace certs +} // namespace pvxs diff --git a/certs/authn/auth.h b/certs/authn/auth.h new file mode 100644 index 000000000..e73efcd75 --- /dev/null +++ b/certs/authn/auth.h @@ -0,0 +1,131 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_AUTH_H +#define PVXS_AUTH_H + +#include +#include +#include + +#include + +#include "ccrmanager.h" +#include "certfactory.h" +#include "configstd.h" +#include "ownedptr.h" +#include "security.h" + +namespace pvxs { +namespace certs { + +#define MAX_AUTH_NAME_LEN 256 + +/** + * @class Auth + * @brief Abstract class for authentication operations. + * + * The Auth class provides an interface for retrieving credentials and + * creating certificate creation request. + */ +using namespace certs; +class Auth { + public: + std::string type_; + std::vector verifier_fields_; + + // Constructor and Destructor + Auth(const std::string &type, const std::vector &verifier_fields) : type_(type), verifier_fields_(verifier_fields) {}; + virtual ~Auth() = default; + + virtual std::shared_ptr getCredentials(const ConfigStd &config) const = 0; + virtual std::shared_ptr createCertCreationRequest(const std::shared_ptr &credentials, + const std::shared_ptr &key_pair, const uint16_t &usage) const; + // Called inside PVACMS to verify request + // if an out-of-band authentication is used then it will check the signature + // established by the signCcrPayloadIfNeeded() method + virtual bool verify(const Value ccr, std::function signature_verifier) const = 0; + // @brief: If implemented, signCcrPayloadIfNeeded(), will make a call to + // PVACMS server through an authentication-method specific API to verify the + // contents of the CCR match the authentication-method's credentials On the + // server the CCR payroll is signed if the credentials match and left blank + // otherwise. On the server later in the process the verify function for + // this type of authentication-method will mandate a check of the signature + // which will fail if it has not been set. + // + // Only used if the authentication method + // requires such out-of-band authentication + // + // Return false if we don't need this (default) - only implement in + // subclasses if needed + virtual bool signCcrPayloadIfNeeded(const Value ccr, std::string &signature) const { return false; }; + + virtual std::string processCertificateCreationRequest(const std::shared_ptr &ccr) const; + + std::shared_ptr createKeyPair(const ConfigCommon &config); + + protected: + // Called to have a standard presentation of the CCR for the + // purposes of generating and verifying signatures + inline const std::string ccrToString(const Value &ccr) const { + return SB() << ccr["type"].as() << ccr["name"].as() << ccr["country"].as() + << ccr["organization"].as() << ccr["organization_unit"].as() << ccr["not_before"].as() + << ccr["not_after"].as() << ccr["usage"].as(); + } + + private: + CCRManager ccr_manager_{}; +}; + +/** + * @brief Function to cast a pointer to a base class into a pointer to a + * subclass + * + * This function checks if the given class S is a subclass of the given base + * class C, then casts the given argument of type C into a pointer to S. + * + * @tparam S The derived class type + * @tparam C The base class type + * @param baseClass A shared pointer to the base class object + * @return A shared pointer to the derived class object if it is a subclass of + * the base class, nullptr otherwise. + * + * @throws std::bad_cast If the cast from base class to derived class fails + * @throws std::invalid_argument If S is not a subclass of C + * + * @note This function uses std::is_base_of to check for subclass relationship + * and std::dynamic_pointer_cast for safe casting from base class to derived + * class. + * + * @code + * + * // Example usage: + * + * class BaseClass {}; + * class DerivedClass : public BaseClass {}; + * + * std::shared_ptr base = std::make_shared(); + * + * std::shared_ptr derived = castAs(base); + * + * if (derived != nullptr) { + * // Successfully casted to derived class + * } else { + * // Not a subclass of derived class + * } + * + * @endcode + */ +template +inline std::shared_ptr castAs(const std::shared_ptr &baseClass) { + static_assert(std::is_base_of::value, "not a subclass"); + return std::dynamic_pointer_cast(baseClass); +} + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_AUTH_H diff --git a/certs/authn/authn.h b/certs/authn/authn.h new file mode 100644 index 000000000..986a53239 --- /dev/null +++ b/certs/authn/authn.h @@ -0,0 +1,20 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_AUTHN_H_ +#define PVXS_AUTHN_H_ + +class Config { + public: +}; + +// Interface to create Config objects +class ConfigFactoryInterface { + public: + virtual Config* create() = 0; +}; + +#endif // PVXS_AUTHN_H_ diff --git a/certs/authn/ccrmanager.cpp b/certs/authn/ccrmanager.cpp new file mode 100644 index 000000000..c519adcd8 --- /dev/null +++ b/certs/authn/ccrmanager.cpp @@ -0,0 +1,40 @@ +// Created on 19/09/2024. +// +#include "ccrmanager.h" + +#include + +#include "client.h" +#include "pvacms.h" +#include "security.h" + +DEFINE_LOGGER(auths, "pvxs.certs.auth"); + +namespace pvxs { +namespace certs { + +using namespace members; + +std::string CCRManager::createCertificate(const std::shared_ptr &cert_creation_request) const { + auto uri = nt::NTURI({}).build(); + uri += {Struct("query", CCR_PROTOTYPE(cert_creation_request->verifier_fields))}; + auto arg = uri.create(); + + // Set values of request argument + arg["path"] = RPC_CERT_CREATE; + arg["query"].from(cert_creation_request->ccr); + + auto ctxt(client::Context::fromEnv()); + auto value(ctxt.rpc(RPC_CERT_CREATE, arg).exec()->wait(5.0)); + + log_info_printf(auths, "X.509 CLIENT certificate%s\n", ""); + log_info_printf(auths, "%s\n", value["status.value.index"].as().c_str()); + log_info_printf(auths, "%s\n", value["state"].as().c_str()); + log_info_printf(auths, "%llu\n", value["serial"].as()); + log_info_printf(auths, "%s\n", value["issuer"].as().c_str()); + log_info_printf(auths, "%s\n", value["certid"].as().c_str()); + log_info_printf(auths, "%s\n", value["statuspv"].as().c_str()); + return value["cert"].as(); +} +} // namespace certs +} // namespace pvxs diff --git a/certs/authn/ccrmanager.h b/certs/authn/ccrmanager.h new file mode 100644 index 000000000..1b3439885 --- /dev/null +++ b/certs/authn/ccrmanager.h @@ -0,0 +1,19 @@ +// Created on 19/09/2024. +// + +#ifndef PVXS_CCRMANAGER_H_ +#define PVXS_CCRMANAGER_H_ + +#include "security.h" + +namespace pvxs { +namespace certs { + +class CCRManager { + public: + std::string createCertificate(const std::shared_ptr& cert_creation_request) const; +}; +} // namespace certs +} // namespace pvxs + +#endif // PVXS_CCRMANAGER_H_ diff --git a/certs/authn/jwt/Makefile b/certs/authn/jwt/Makefile new file mode 100644 index 000000000..4bc692c6c --- /dev/null +++ b/certs/authn/jwt/Makefile @@ -0,0 +1,8 @@ +# This is a Makefile fragment, see cert/authn/Makefile. + +SRC_DIRS += $(AUTHN)/jwt + +PROD += authnjwt +authnjwt_INC += authnjwt.h + +authnjwt_SRCS += authnjwt.cpp diff --git a/certs/authn/jwt/authnjwt.cpp b/certs/authn/jwt/authnjwt.cpp new file mode 100644 index 000000000..c4810ff2e --- /dev/null +++ b/certs/authn/jwt/authnjwt.cpp @@ -0,0 +1,108 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "authnjwt.h" + +#include +#include +#include +#include +#include +#include + +#include + +DEFINE_LOGGER(auths, "pvxs.auth.jwt"); + +namespace pvxs { +namespace certs { + +void handle_request(int client_socket) { + char buffer[1024] = {0}; + read(client_socket, buffer, 1024); + + std::string request(buffer); + log_info_printf(auths, "Received Request: %s\n", request.c_str()); + + // Parse request to find the token + std::string method = request.substr(0, request.find(" ")); + std::string uri = request.substr(request.find(" "), request.find("HTTP/1.1") - request.find(" ")); + + if (method == "POST" && uri.find(TOKEN_ENDPOINT) != std::string::npos) { + size_t token_pos = request.find("token="); + if (token_pos != std::string::npos) { + std::string token = request.substr(token_pos + 6); // Length of 'token=' is 6 + token = token.substr(0, token.find("&")); + log_info_printf(auths, "Received Token: %s\n", token.c_str()); + + std::string response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nToken received"; + send(client_socket, response.c_str(), response.size(), 0); + } else { + std::string response = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing 'token' parameter"; + send(client_socket, response.c_str(), response.size(), 0); + } + } else { + std::string response = "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\nNot Found"; + send(client_socket, response.c_str(), response.size(), 0); + } + + close(client_socket); +} + +} // namespace certs +} // namespace pvxs + +int main() { + int server_fd, new_socket; + struct sockaddr_in address; + int addrlen = sizeof(address); + + // Creating socket file descriptor + if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { + log_err_printf(auths, "Socket Error: %s\n", ""); + perror("socket failed"); + exit(EXIT_FAILURE); + } + + // Forcefully attaching socket to the port 8080 + int opt = 1; + if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) { + log_err_printf(auths, "Setsockopt Error: %s\n", ""); + perror("setsockopt"); + close(server_fd); + exit(EXIT_FAILURE); + } + + address.sin_family = AF_INET; + address.sin_addr.s_addr = INADDR_ANY; + address.sin_port = htons(pvxs::certs::PORT); + + // Forcefully attaching socket to the port 8080 + if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) { + log_err_printf(auths, "Bind failure: %s\n", ""); + close(server_fd); + exit(EXIT_FAILURE); + } + if (listen(server_fd, 3) < 0) { + log_err_printf(auths, "Listen failure: %s\n", ""); + close(server_fd); + exit(EXIT_FAILURE); + } + + log_info_printf(auths, "Server listening on port: %d\n", pvxs::certs::PORT); + + while (true) { + if ((new_socket = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) { + log_err_printf(auths, "Accept failure: %s\n", ""); + close(server_fd); + exit(EXIT_FAILURE); + } + + std::thread(pvxs::certs::handle_request, new_socket).detach(); + } + + return 0; +} diff --git a/certs/authn/jwt/authnjwt.h b/certs/authn/jwt/authnjwt.h new file mode 100644 index 000000000..efc142f79 --- /dev/null +++ b/certs/authn/jwt/authnjwt.h @@ -0,0 +1,53 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_AUTH_JWT_H +#define PVXS_AUTH_JWT_H + +#include +#include +#include + +#include + +#include +#include +#include +#include + +//#include "auth.h" +#include "ownedptr.h" +#include "security.h" + +#define PVXS_JWT_AUTH_TYPE "jwt" + +namespace pvxs { +namespace certs { + +const int PORT = 8080; +const std::string TOKEN_ENDPOINT = "/token"; + +/** + * Definition of the JWT identification type that contains the token and any + * other required identification info. + */ +struct Jwt { + std::string token; + int32_t kid; // key ID if present +}; + +/** + * The subclass of Credentials that contains the JwtAuth specific + * identification object + */ +struct JwtCredentials : public Credentials { + Jwt jwt; // jwt +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_AUTH_JWT_H diff --git a/certs/authn/jwt/configjwt.cpp b/certs/authn/jwt/configjwt.cpp new file mode 100644 index 000000000..807c576e6 --- /dev/null +++ b/certs/authn/jwt/configjwt.cpp @@ -0,0 +1,42 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "configjwt.h" + +std::unique_ptr getConfigFactory() { + struct ConfigCmsFactory : public ConfigFactoryInterface { + std::unique_ptr create() override { + // EPICS_AUTH_JWT_REQUEST_FORMAT + if (pickone({"EPICS_AUTH_JWT_REQUEST_FORMAT"})) { + self.jwt_request_format = pickone.val; + } + + // EPICS_AUTH_JWT_REQUEST_METHOD + if (pickone({"EPICS_AUTH_JWT_REQUEST_METHOD"})) { + self.jwt_request_method = pickone.val == "POST" ? Config::POST : Config::GET; + } + + // EPICS_AUTH_JWT_RESPONSE_FORMAT + if (pickone({"EPICS_AUTH_JWT_RESPONSE_FORMAT"})) { + self.jwt_response_format = pickone.val; + } + + // EPICS_AUTH_JWT_TRUSTED_URI + if (pickone({"EPICS_AUTH_JWT_TRUSTED_URI"})) { + self.jwt_trusted_uri = pickone.val; + } + + // EPICS_AUTH_JWT_USE_RESPONSE_CODE + if (pickone({"EPICS_AUTH_JWT_USE_RESPONSE_CODE"})) { + self.jwt_use_response_code = parseTo(pickone.val); + } + + return std::make_unique(); + } + }; + + return std::make_unique(); +} diff --git a/certs/authn/jwt/configjwt.h b/certs/authn/jwt/configjwt.h new file mode 100644 index 000000000..ee3fe4aa9 --- /dev/null +++ b/certs/authn/jwt/configjwt.h @@ -0,0 +1,31 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CONFIGJWT_H_ +#define PVXS_CONFIGJWT_H_ + +#include + +#include "ownedptr.h" + +#include "certconfig.h" + +class ConfigJwt : public Config { + public: + /** + * @brief The JWT token + */ + std::string jwt_token; +}; + +class ConfigJwtFactory : public ConfigFactoryInterface { + public: + std::unique_ptr create() override { + return std::make_unique(); + } +}; + +#endif //PVXS_CONFIGJWT_H_ diff --git a/certs/authn/krb/Makefile b/certs/authn/krb/Makefile new file mode 100644 index 000000000..1f3718dc2 --- /dev/null +++ b/certs/authn/krb/Makefile @@ -0,0 +1,8 @@ +# This is a Makefile fragment, see cert/authn/Makefile. + +SRC_DIRS += $(AUTHN)/krb + +PROD += authnkrb +authnkrb_INC += authnkrb.h + +authnkrb_SRCS += authnkrb.cpp diff --git a/certs/authn/krb/authnkrb.cpp b/certs/authn/krb/authnkrb.cpp new file mode 100644 index 000000000..7c5c39bd6 --- /dev/null +++ b/certs/authn/krb/authnkrb.cpp @@ -0,0 +1,302 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "authnkrb.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include "auth.h" +#include "authregistry.h" +#include "security.h" + +namespace pvxs { +namespace security { + +DEFINE_LOGGER(auths, "pvxs.security.auth.krb"); + +// Get rid of OSX 10.7 and greater deprecation warnings. +#if defined(__APPLE__) && defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + +std::shared_ptr KrbAuth::getCredentials(const impl::ConfigCommon &config) const { + log_debug_printf(auths, + "\n******************************************\nKerberos " + "Authenticator: %s\n", + "Begin acquisition"); + + // Create KrbCredentials shared_ptr + auto kerberos_credentials = std::make_shared(); + + return kerberos_credentials; +} + +/** + * @brief Creates a signed certificate for the Kerberos authentication type. + * + * This function generates a signed certificate using the provided credentials + * and key pair. The certificate creation request (CSR) will be passed through + * the custom requestor to PVACMS (the CA) to sign the included X.509 + * certificate for client or server authentication, depending on the usage. + * + * @param credentials The credentials used to create the signed certificate. + * @param key_pair The key pair containing the public key used for signing. + * @param usage the desired certificate usage bitmask + * @return The certificate creation request. + */ +std::shared_ptr KrbAuth::createCertCreationRequest(const std::shared_ptr &credentials, + const std::shared_ptr &key_pair, + const uint16_t &usage) const { + auto krb_credentials = castAs(credentials); + + // Call subclass to set up common CSR fields + auto cert_creation_request = Auth::createCertCreationRequest(credentials, key_pair, usage); + + OM_uint32 major_status, minor_status; + gss_ctx_id_t context = GSS_C_NO_CONTEXT; + + // We use GSS_C_NO_CREDENTIAL to specify that we want to use the default + // credentials Usually, the default credential is obtained from the system's + // Kerberos TGT + + // Similarly, for the target name, it will be of the format service@hostname + gss_name_t target_name; + // TODO remove server hardcoding. Determine target PVACMS server by config + gssNameFromString("PVACMS@SLAC.STANFORD.EDU", target_name); + + // Initialize the context from a kerberos ticket + gss_buffer_desc output_token = GSS_C_EMPTY_BUFFER; + major_status = gss_init_sec_context(&minor_status, GSS_C_NO_CREDENTIAL, &context, target_name, + krb5_oid, // Kerberos 5 credentials only + GSS_C_MUTUAL_FLAG | GSS_C_REPLAY_FLAG, 0, GSS_C_NO_CHANNEL_BINDINGS, + GSS_C_NO_BUFFER, /* No input token provided because we haven't got + anything from other side, and we won't use it + anyway*/ + NULL, &output_token, NULL, NULL); + + if (GSS_ERROR(major_status)) { + throw std::runtime_error(SB() << "Failed to initialize kerberos security context: " + << gssErrorDescription(major_status, minor_status)); + } + + // Add token to credentials + krb_credentials->token = std::vector(static_cast(output_token.value), + static_cast(output_token.value) + output_token.length); + + // Clean up + gss_delete_sec_context(&minor_status, &context, &output_token); + + // KRB specific fields + shared_array token_bytes(krb_credentials->token.begin(), krb_credentials->token.end()); + cert_creation_request->credentials = krb_credentials; + cert_creation_request->ccr["verifier.token"] = token_bytes; + + return cert_creation_request; +} + +/** + * @brief Verify the Kerberos authentication against the provided CCR. + * + * This function verifies the Kerberos authentication by comparing the provided + * CCR with the Kerberos authentication information provided in the GSS-API + * verifier.token. + * + * @param ccr The CCR which includes the information required in the certificate + * as well as the verifier.token created in the client capturing the kerberos + * ticket and wrapping it as a GSS-API token + * @param compareFunc We don't use the side-band verification with kerberos so + * we won't use this callback. + * @return True if the kerberos credentials extracted from the token and + * validated by the KDC match those in the CCR, false otherwise. + */ +bool KrbAuth::verify(const Value ccr, std::function) const { + // Verify that the token in the ccr is created from a ticket generated by + // the same KDC I'm configured in as a service + + // Extract and decode client token from ccr + auto token_bytes = ccr["verifier.token"].as>(); + std::vector vec_bytes(token_bytes.begin(), token_bytes.end()); + + gss_buffer_desc client_token; + client_token.length = vec_bytes.size(); + + std::unique_ptr buffer(new uint8_t[client_token.length]); + client_token.value = buffer.get(); + std::copy(vec_bytes.begin(), vec_bytes.end(), static_cast(client_token.value)); + + // Get ready for accepting the client's token + gss_ctx_id_t context = GSS_C_NO_CONTEXT; + gss_buffer_desc server_token; + OM_uint32 major_status; + OM_uint32 minor_status; + + // The server accepts the context using the client's token + major_status = gss_accept_sec_context(&minor_status, &context, + GSS_C_NO_CREDENTIAL, // use the default credential + &client_token, GSS_C_NO_CHANNEL_BINDINGS, + NULL, // don't need the name of client + krb5_oid_ptr, // Kerberos 5 credentials only + &server_token, + NULL, // don't care about ret_flags + NULL, // ignore time_rec + NULL // ignore delegated_cred_handle + ); + + // Note: If the context is not fully established, major_status will be + // GSS_S_CONTINUE_NEEDED, and we would need to send the server_token back to + // the client and run this process again until it returns GSS_S_COMPLETE. + // However, as we are only interested in kerberos tickets and don't care + // about mutual authentication here, we won't ever send anything back and + // are only interested in finding out if the context can be created with the + // client token, and then what the context can tell us about the peer + + if (GSS_ERROR(major_status)) { + throw std::runtime_error(SB() << "Verify Credentials: Failed to validate kerberos token: " + << gssErrorDescription(major_status, minor_status)); + } + + // Now get the peer credentials information from the context + + // Retrieve the credentials + gss_name_t peer_name; + OM_uint32 peer_lifetime; + gss_OID_set peer_mechanisms; + int peer_credential_usage; + time_t now = time(NULL); + + major_status = + gss_inquire_cred(&minor_status, nullptr, &peer_name, &peer_lifetime, &peer_credential_usage, &peer_mechanisms); + throw std::runtime_error(SB() << "Verify Credentials: Failed to inquire credentials: " + << gssErrorDescription(major_status, minor_status)); + + // Get the principal name + gss_buffer_desc peer_name_buffer; + major_status = gss_display_name(&minor_status, peer_name, &peer_name_buffer, NULL); + if (GSS_ERROR(major_status)) { + gss_release_name(&minor_status, &peer_name); + throw std::runtime_error(SB() << "Verify Credentials: Failed to get principal name: " + << gssErrorDescription(major_status, minor_status)); + } + + std::string peer_principal(static_cast(peer_name_buffer.value), peer_name_buffer.length); + gss_release_buffer(&minor_status, &peer_name_buffer); + gss_release_name(&minor_status, &peer_name); + + // Check if the credentials are for Kerberos + if (peer_mechanisms->elements != krb5_oid) { + throw std::runtime_error(SB() << "Verify Credentials: Client credentials are not for Kerberos " + "mechanism: " + << gssErrorDescription(major_status, minor_status)); + } + + // Now, 'principal' contains the principal name, 'lifetime' contains the + // remaining lifetime of the credentials in seconds, and 'expiration' + // contains the ticket expiration time + + // Verify the peer credentials against ccr fields + // ccr["name"] == peer_principal(before @ sign) + // ccr["organization"] == peer_principal(after @ sign) + // ccr["organization_unit"] == blank + // ccr["country"] == blank + // ccr["type"] == "krb" + // ccr["not_before"] < now+peer_lifetime + // ccr["not_after"] <= now+peer_lifetime + + // Split out name and organization if the principal has an at sign + std::size_t found; + auto peer_principal_name(ccr["name"].as()); + std::string peer_principal_organization; + if ((found = peer_principal_name.find('@')) != std::string::npos) { + peer_principal_organization = peer_principal_name.substr(found + 1); + peer_principal_name.resize(found); + } + // Now the tests + if (peer_principal_name.compare(ccr["name"].as()) != 0) { + throw std::runtime_error(SB() << "Verify Credentials: Kerberos name does not match name in CCR"); + } + if (peer_principal_organization.compare(ccr["organization"].as()) != 0) { + throw std::runtime_error(SB() << "Verify Credentials: Kerberos organization " + "does not match name in CCR"); + } + if (!ccr["organization_unit"].as().empty()) { + throw std::runtime_error(SB() << "Verify Credentials: Organization Unit in CCR not blank"); + } + if (!ccr["country"].as().empty()) { + throw std::runtime_error(SB() << "Verify Credentials: Country in CCR not blank"); + } + if (ccr["type"].as().compare(PVXS_KRB_AUTH_TYPE) != 0) { + throw std::runtime_error(SB() << "Verify Credentials: Type of CCR not Kerberos"); + } + if (ccr["not_before"].as() >= now + peer_lifetime) { + throw std::runtime_error(SB() << "Verify Credentials: CCR not_before after " + "end of kerberos ticket lifetime"); + } + + if (ccr["not_after"].as() > now + peer_lifetime) { + throw std::runtime_error(SB() << "Verify Credentials: CCR not_after after " + "end of kerberos ticket lifetime"); + } + + return true; +} + +void KrbAuth::gssNameFromString(const std::string &name, gss_name_t &target_name) const { + OM_uint32 major_status, minor_status; + gss_buffer_desc name_buf; + gss_OID name_type = GSS_C_NT_HOSTBASED_SERVICE; + + /* initialize the name buffer */ + name_buf.value = (void *)name.c_str(); + name_buf.length = name.size() + 1; + + /* import the name */ + major_status = gss_import_name(&minor_status, &name_buf, name_type, &target_name); + if (GSS_ERROR(major_status)) { + throw std::runtime_error(SB() << "Kerberos can't create name from \"" << name + << "\" : " << gssErrorDescription(major_status, minor_status)); + } +} + +std::string KrbAuth::gssErrorDescription(OM_uint32 major_status, OM_uint32 minor_status) const { + OM_uint32 msg_ctx; + OM_uint32 minor; + gss_buffer_desc status_string; + auto error_description = SB(); + char context[GSS_STATUS_BUFFER_LEN] = ""; + + msg_ctx = 0; + while (!gss_display_status(&minor, major_status, GSS_C_GSS_CODE, GSS_C_NO_OID, &msg_ctx, &status_string)) { + snprintf(context, GSS_STATUS_BUFFER_LEN, "%.*s\n", (int)status_string.length, (char *)status_string.value); + error_description << context; + gss_release_buffer(&minor, &status_string); + } + + msg_ctx = 0; + while (!gss_display_status(&minor, minor_status, GSS_C_MECH_CODE, GSS_C_NULL_OID, &msg_ctx, &status_string)) { + snprintf(context, GSS_STATUS_BUFFER_LEN, "%.*s\n", (int)status_string.length, (char *)status_string.value); + error_description << context; + gss_release_buffer(&minor, &status_string); + } + + return error_description.str(); +} + +#if defined(__APPLE__) && defined(__clang__) +#pragma GCC diagnostic pop +#endif + +} // namespace security +} // namespace pvxs diff --git a/certs/authn/krb/authnkrb.h b/certs/authn/krb/authnkrb.h new file mode 100644 index 000000000..a30a87f42 --- /dev/null +++ b/certs/authn/krb/authnkrb.h @@ -0,0 +1,116 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_AUTH_KERB_H +#define PVXS_AUTH_KERB_H + +#include +#include +#include + +#include + +#include +#include +#include + +#include "auth.h" +#include "authregistry.h" +#include "ownedptr.h" +#include "security.h" + +#define PVXS_KRB_AUTH_TYPE "krb" +#define GSS_STATUS_BUFFER_LEN 1024 + +namespace pvxs { +namespace security { + +// Declarations +extern gss_OID_desc krb5_oid_desc; +extern gss_OID krb5_oid; + +// Get rid of OSX 10.7 and greater deprecation warnings. +#if defined(__APPLE__) && defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + +/** + * The subclass of Credentials that contains the KrbAuth specific + * identification object + */ +struct KrbCredentials : public Credentials { + // gss-api token for default Kerberos Ticket Granting Ticket + // (TGT) on the system, stored as a byte array + std::vector token; + + KrbCredentials() {} + + ~KrbCredentials() {} +}; + +/** + * @class KrbAuth + * @brief The KrbAuth class provides Kerberos authentication + * functionality. + * + * This class is responsible for retrieving credentials for users that have been + * authenticated against a Kerberos server. It inherits from the Auth + * base class. + * + * In order to use the KrbAuth, it must be registered with the + * CertFactory using the REGISTER_AUTHENTICATOR() macro. + * + * The KrbAuth class implements the getCredentials() and + * createCertCreationRequest() methods. The getCredentials() method returns the + * credentials used for authentication. The createCertCreationRequest() method + * creates a kerberos specific certificate creation request using the provided + * credentials. + */ +class KrbAuth : public Auth { + public: + REGISTER_AUTHENTICATOR(); + + // Constructor. Adds in kerberos specific fields (ticket) to the verifier + // field of the ccr + KrbAuth() + : Auth(PVXS_KRB_AUTH_TYPE, {Member(TypeCode::Int8A, "token")}), + krb5_oid(&krb5_oid_desc), + krb5_oid_ptr(&krb5_oid) { + krb5_oid_desc.length = 9; + krb5_oid_desc.elements = (void *)"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"; + }; + ~KrbAuth() override = default; + + gss_OID krb5_oid; + gss_OID *krb5_oid_ptr; + + std::shared_ptr getCredentials(const impl::ConfigCommon &config) const override; + + std::shared_ptr createCertCreationRequest(const std::shared_ptr &credentials, + const std::shared_ptr &key_pair, + const uint16_t &usage) const override; + + bool verify( + const Value ccr, + std::function signature_verifier) const override; + + private: + gss_OID_desc krb5_oid_desc; + + std::string gssErrorDescription(OM_uint32 major_status, OM_uint32 minor_status) const; + + void gssNameFromString(const std::string &name, gss_name_t &target_name) const; +}; + +#if defined(__APPLE__) && defined(__clang__) +#pragma GCC diagnostic pop +#endif + +} // namespace security +} // namespace pvxs + +#endif // PVXS_AUTH_KERB_H diff --git a/certs/authn/krb/configkrb.cpp b/certs/authn/krb/configkrb.cpp new file mode 100644 index 000000000..1b0ccadb5 --- /dev/null +++ b/certs/authn/krb/configkrb.cpp @@ -0,0 +1,27 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "configkrb.h" + +std::unique_ptr getConfigFactory() { + struct ConfigCmsFactory : public ConfigFactoryInterface { + std::unique_ptr create() override { + // EPICS_AUTH_KRB_KEYTAB + if (pickone({"EPICS_AUTH_KRB_KEYTAB"})) { + self.krb_keytab = pickone.val; + } + + // EPICS_AUTH_KRB_REALM + if (pickone({"EPICS_AUTH_KRB_REALM"})) { + self.krb_realm = pickone.val; + } + + return std::make_unique(); + } + }; + + return std::make_unique(); +} diff --git a/certs/authn/krb/configkrb.h b/certs/authn/krb/configkrb.h new file mode 100644 index 000000000..597d7a627 --- /dev/null +++ b/certs/authn/krb/configkrb.h @@ -0,0 +1,46 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CONFIGKRB_H_ +#define PVXS_CONFIGKRB_H_ + +#include + +#include "ownedptr.h" + +#include "certconfig.h" + +class ConfigKrb : public Config { + public: + /** + * @brief This is the string to which PVACMS/CLUSTER is prepended to + * create the service principal to be added to the Kerberos KDC to + * enable Kerberos ticket verification by the PVACMS. + * + * It is used in an EPICS agent when creating a GSSAPI context to + * create a token to send to the PVACMS to be validated, and used by + * the PVACMS to create another GSSAPI context to decode the token + * and validate the CCR. + * + * There is no default so this value *must* be + * specified if Kerberos support is configured. + * + * The KDC will share a keytab file containing the secret key + * for the PVACMS/CLUSTER service and it will be made available to + * all members of the cluster but protected so no other processes + * or users can access it. + */ + std::string krb_realm; +}; + +class ConfigKrbFactory : public ConfigFactoryInterface { + public: + std::unique_ptr create() override { + return std::make_unique(); + } +}; + +#endif //PVXS_CONFIGKRB_H_ diff --git a/certs/authn/ldap/authnldap.cpp b/certs/authn/ldap/authnldap.cpp new file mode 100644 index 000000000..4e87f8397 --- /dev/null +++ b/certs/authn/ldap/authnldap.cpp @@ -0,0 +1,56 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "authnldap.h" + +#include +#include + +#include + +#include "auth.h" +#include "authregistry.h" +#include "security.h" + +namespace pvxs { +namespace security { + +DEFINE_LOGGER(auths, "pvxs.security.auth.LDAP"); + +std::shared_ptr LdapAuth::getCredentials(const impl::ConfigCommon &config) const { + log_debug_printf(auths, + "\n******************************************\nLDAP " + "Authenticator: %s\n", + "Begin acquisition"); + + auto ldap_credentials = std::make_shared(); + throw std::runtime_error("Process not authenticated with LDAP"); + return ldap_credentials; +}; + +std::shared_ptr LdapAuth::createCertCreationRequest( + const std::shared_ptr &credentials, const std::shared_ptr &key_pair, const uint16_t &usage) const { + auto ldap_credentials = castAs(credentials); + + auto cert_creation_request = Auth::createCertCreationRequest(credentials, key_pair, usage); + + return cert_creation_request; +}; + +std::string LdapAuth::processCertificateCreationRequest(const std::shared_ptr &ccr) const { + throw std::runtime_error("Custom Signer: Failed to sign certificate."); + return nullptr; +} + +bool LdapAuth::verify(const Value ccr, + std::function signature_verifier) const { + // Verify that the signature provided in the CCR that was established in the + // GSSAPI session was validated and signed + return signature_verifier(ccrToString(ccr), ccr["verifier.signature"].as()); +} + +} // namespace security +} // namespace pvxs diff --git a/certs/authn/ldap/authnldap.h b/certs/authn/ldap/authnldap.h new file mode 100644 index 000000000..4bfecb3cb --- /dev/null +++ b/certs/authn/ldap/authnldap.h @@ -0,0 +1,86 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_AUTH_LDAP_H +#define PVXS_AUTH_LDAP_H + +#include +#include +#include + +#include +#include +#include + +#include "auth.h" +#include "authregistry.h" +#include "certfactory.h" +#include "ownedptr.h" +#include "security.h" + +#define PVXS_LDAP_AUTH_TYPE "ldap" + +namespace pvxs { +namespace security { + +/** + * Definition of the LDAP identification type containing all required LDAP + * credential information. + */ +struct LdapId { + int i; // TODO placeholder +}; + +/** + * The subclass of Credentials that contains the LdapAuth specific + * identification object + */ +struct LdapCredentials : Credentials { + LdapId id; // LDAP ID +}; + +/** + * @class LdapAuth + * @brief The LdapAuth class provides LDAP authentication + * functionality. + * + * This class is responsible for retrieving credentials for users that have been + * authenticated against an LDAP server. It inherits from the Auth base + * class. + * + * In order to use the LdapAuth, it must be registered with the + * CertFactory using the REGISTER_AUTHENTICATOR() macro. + * + * The LdapAuth class implements the getCredentials() and + * createCertCreationRequest() methods. The getCredentials() method returns the + * credentials used for authentication. The createCertCreationRequest() method + * creates a signed certificate using the provided credentials. + */ +class LdapAuth : public Auth { + public: + REGISTER_AUTHENTICATOR(); + + // Constructor + LdapAuth() : Auth(PVXS_LDAP_AUTH_TYPE, {}) {}; + ~LdapAuth() override = default; + + std::shared_ptr getCredentials(const impl::ConfigCommon &config) const override; + + std::shared_ptr createCertCreationRequest(const std::shared_ptr &credentials, + const std::shared_ptr &key_pair, + const uint16_t &usage) const override; + + bool verify( + const Value ccr, + std::function signature_verifier) const override; + + std::string processCertificateCreationRequest(const std::shared_ptr &ccr) const override; +}; + +} // namespace security +} // namespace pvxs + +#endif // PVXS_AUTH_LDAP_H diff --git a/certs/authn/ldap/configldap.cpp b/certs/authn/ldap/configldap.cpp new file mode 100644 index 000000000..5c0b38e2d --- /dev/null +++ b/certs/authn/ldap/configldap.cpp @@ -0,0 +1,52 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "configldap.h" + +std::unique_ptr getConfigFactory() { + struct ConfigCmsFactory : public ConfigFactoryInterface { + std::unique_ptr create() override { + // EPICS_AUTH_LDAP_ACCOUNT + if (pickone({"EPICS_AUTH_LDAP_ACCOUNT"})) { + self.ldap_account = pickone.val; + } + + // EPICS_AUTH_LDAP_ACCOUNT_PWD_FILE + if (pickone({"EPICS_AUTH_LDAP_ACCOUNT_PWD_FILE"})) { + auto filepath = pickone.val; + self.ensureDirectoryExists(filepath); + try { + self.ldap_account_password = self.getFileContents(filepath); + } catch (std::exception &e) { + log_err_printf(serversetup, "error reading password file: %s. %s", filepath.c_str(), e.what()); + } + } + + // EPICS_AUTH_LDAP_HOST + if (pickone({"EPICS_AUTH_LDAP_HOST"})) { + self.ldap_host = pickone.val; + } + + // EPICS_AUTH_LDAP_PORT + if (pickone({"EPICS_AUTH_LDAP_PORT"})) { + try { + self.ldap_port = parseTo(pickone.val); + } catch (std::exception &e) { + log_err_printf(serversetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); + } + } + + // EPICS_AUTH_LDAP_SEARCH_ROOT + if (pickone({"EPICS_AUTH_LDAP_SEARCH_ROOT"})) { + self.ldap_search_root = pickone.val; + } + + return std::make_unique(); + } + }; + + return std::make_unique(); +} diff --git a/certs/authn/ldap/configldap.h b/certs/authn/ldap/configldap.h new file mode 100644 index 000000000..03977f7ac --- /dev/null +++ b/certs/authn/ldap/configldap.h @@ -0,0 +1,32 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CONFIGLDAP_H_ +#define PVXS_CONFIGLDAP_H_ + +#include + +#include "ownedptr.h" + +#include "certconfig.h" + +class ConfigLdap : public Config { + public: + std::string ldap_account; + std::string ldap_account_password; + std::string ldap_host; + unsigned short ldap_port; + std::string ldap_search_root; +}; + +class ConfigLdapFactory : public ConfigFactoryInterface { + public: + std::unique_ptr create() override { + return std::make_unique(); + } +}; + +#endif //PVXS_CONFIGLDAP_H_ diff --git a/certs/authn/std/Makefile b/certs/authn/std/Makefile new file mode 100644 index 000000000..b98668647 --- /dev/null +++ b/certs/authn/std/Makefile @@ -0,0 +1,10 @@ +# This is a Makefile fragment, see cert/authn/Makefile. + +SRC_DIRS += $(AUTHN)/std + +PROD += authnstd +authnstd_INC += authnstd.h +authnstd_INC += configstd.h + +authnstd_SRCS += authnstd.cpp +authnstd_SRCS += configstd.cpp diff --git a/certs/authn/std/authnstd.cpp b/certs/authn/std/authnstd.cpp new file mode 100644 index 000000000..f9fc8b74d --- /dev/null +++ b/certs/authn/std/authnstd.cpp @@ -0,0 +1,486 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "authnstd.h" + +#include +#include + +#include + +#include "certfilefactory.h" +#include "configstd.h" +#include "openssl.h" +#include "p12filefactory.h" +#include "utilpvt.h" + +DEFINE_LOGGER(auths, "pvxs.auth.std"); + +namespace pvxs { +namespace certs { + +/** + * @brief Extract the country code from a locale string + * + * @param locale_str the locale string to extract the country code from + * @return the country code extracted from the locale string + */ +static std::string extractCountryCode(const std::string &locale_str) { + // Look for underscore + auto pos = locale_str.find('_'); + if (pos == std::string::npos || pos + 3 > locale_str.size()) { + return ""; + } + + std::string country_code = locale_str.substr(pos + 1, 2); + std::transform(country_code.begin(), country_code.end(), country_code.begin(), ::toupper); + return country_code; +} + +/** + * @brief Get the current country code of where the process is running + * This returns the two letter country code. It is always upper case. + * For example for the United States it returns US, and for France, FR. + * + * @return the current country code of where the process is running + */ +static std::string getCountryCode() { + // 1. Try from std::locale("") + { + std::locale loc(""); + std::string name = loc.name(); + if (name != "C" && name != "POSIX") { + std::string cc = extractCountryCode(name); + if (!cc.empty()) { + return cc; + } + } + } + + // 2. If we failed, try the LANG environment variable + { + const char *lang = std::getenv("LANG"); + if (lang && *lang) { + std::string locale_str(lang); + std::string cc = extractCountryCode(locale_str); + if (!cc.empty()) { + return cc; + } + } + } + + // 3. Default to "US" if both attempts failed + return "US"; +} + +/** + * @brief Print the usage message for the authnstd tool + * + * @param argv0 the name of the program + */ +void usage(const char *argv0) { + std::cerr << "Usage: " << argv0 + << " \n" + "\n" + " -v Make more noise.\n" + " -h Show this help message and exit\n" + " -d Shorthand for $PVXS_LOG=\"pvxs.*=DEBUG\". Make a lot of noise.\n" + " -D Run in Daemon mode. Monitors and updates certs as needed\n" + " -V Show version and exit\n" + " -u Usage. client, server, or gateway\n" + " -N Name override the CN subject field\n" + " -O Org override the O subject field\n" + " -o Override the OU subject field\n" + " -C Override the C subject field\n" + " \n" + "ENVIRONMENT VARIABLES: at least one mandatory variable must be set\n" + "\tEPICS_PVA_TLS_KEYCHAIN\t\t\tSet name and location of client certificate file (mandatory for clients)\n" + "\tEPICS_PVAS_TLS_KEYCHAIN\t\t\tSet name and location of server certificate file (mandatory for server)\n" + "\tEPICS_PVA_TLS_KEYCHAIN_PWD_FILE\t\tSet name and location of client certificate password file (optional)\n" + "\tEPICS_PVAS_TLS_KEYCHAIN_PWD_FILE\tSet name and location of server certificate password file (optional)\n" + "\tEPICS_PVA_TLS_PKEY\t\t\tSet name and location of client private key file (optional)\n" + "\tEPICS_PVAS_TLS_PKEY\t\t\tSet name and location of server private key file (optional)\n" + "\tEPICS_PVA_TLS_PKEY_PWD_FILE\t\tSet name and location of client private key password file (optional)\n" + "\tEPICS_PVAS_TLS_PKEY_PWD_FILE\t\tSet name and location of server private key password file (optional)\n"; +} + +/** + * @brief Read the command line options for the authnstd tool + * + * @param config the configuration object to update with the command line options + * @param argc the number of command line arguments + * @param argv the command line arguments + * @param verbose the verbose flag + * @param cert_usage the certificate usage + * @param name the name override + * @param org the organization override + * @param ou the organizational unit override + * @param country the country override + * @return the exit status + */ +int readOptions(ConfigStd &config, int argc, char *argv[], bool &verbose, uint16_t &cert_usage, std::string &name, std::string &org, std::string &ou, + std::string &country) { + int opt; + while ((opt = getopt(argc, argv, "vhVdu:N:O:o:DC:")) != -1) { + switch (opt) { + case 'v': + verbose = true; + break; + case 'h': + usage(argv[0]); + return 1; + case 'D': + usage(argv[0]); + std::cerr << "\nNot yet supported: -" << char(optopt) << std::endl; + return 4; + case 'd': + logger_level_set("pvxs.*", Level::Debug); + break; + case 'V': + std::cout << pvxs::version_information; + return 1; + case 'u': { + std::string usage_str = optarg; + if (usage_str == "gateway" || usage_str == "server") { + // Use the Server versions of environment variables + config.tls_cert_filename = config.tls_srv_cert_filename; + config.tls_private_key_filename = config.tls_srv_private_key_filename; + config.tls_cert_password = config.tls_srv_cert_password; + config.tls_private_key_password = config.tls_srv_private_key_password; + config.name = config.server_name; + config.organization = config.server_organization; + config.organizational_unit = config.server_organizational_unit; + config.country = config.server_country; + if (usage_str == "gateway") { + cert_usage = pvxs::ssl::kForClientAndServer; + } else if (usage_str == "server") { + cert_usage = pvxs::ssl::kForServer; + } + } else if (usage_str == "client") { + cert_usage = pvxs::ssl::kForClient; + } else { + usage(argv[0]); + std::cerr << "\nUnknown argument: -" << char(optopt) << " " << usage_str << std::endl; + return 2; + } + } break; + case 'N': + name = optarg; + break; + case 'O': + org = optarg; + break; + case 'o': + ou = optarg; + break; + case 'C': + country = optarg; + break; + default: + usage(argv[0]); + std::cerr << "\nUnknown argument: -" << char(optopt) << std::endl; + return 3; + } + } + + return 0; +} + +/** + * @brief Get the IP address of the current process' host. + * + * This will return the IP address based on the following rules. It will + * look through all the network interfaces and will skip local and self + * assigned addresses. Then it will select any public IP address. + * if no public IP addresses are found then it will return + * the first private IP address that it finds + * + * @return the IP address of the current process' host + */ +std::string getIPAddress() { + struct ifaddrs *if_addr_struct = nullptr; + struct ifaddrs *ifa; + void *tmp_addr_ptr; + std::string chosen_ip; + std::string private_ip; + + getifaddrs(&if_addr_struct); + + std::regex local_address_pattern(R"(^(127\.)|(169\.254\.))"); + std::regex private_address_pattern(R"(^(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(192\.168\.))"); + + for (ifa = if_addr_struct; ifa != nullptr; ifa = ifa->ifa_next) { + if (!ifa->ifa_addr) { + continue; + } + if (ifa->ifa_addr->sa_family == AF_INET) { + // is a valid IPv4 Address + tmp_addr_ptr = &((struct sockaddr_in *)ifa->ifa_addr)->sin_addr; + char address_buffer[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, tmp_addr_ptr, address_buffer, INET_ADDRSTRLEN); + + // Skip local or self-assigned address. If it's a private address, + // remember it. + if (!std::regex_search(address_buffer, local_address_pattern)) { + if (std::regex_search(address_buffer, private_address_pattern)) { + if (private_ip.empty()) { + private_ip = address_buffer; + } + } else { + chosen_ip = address_buffer; + break; // If a public address is found, exit the loop + } + } + } + } + if (if_addr_struct != nullptr) freeifaddrs(if_addr_struct); + + // If no public IP addresses were found, use the first private IP that was + // found. + if (chosen_ip.empty()) { + chosen_ip = private_ip; + } + + return chosen_ip; +} + +/** + * @brief Creates credentials for use in creating an X.509 certificate. + * + * This function retrieves the credentials required for creation of an X.509 + * certificate. It uses the hostname, and the username unless a process name + * is provided in configuration, in which case it replaces the username. + * + * @param config The ConfigCommon object containing the optional process name. + * @return A structure containing the credentials required for creation of + * certificate. + */ +std::shared_ptr AuthStd::getCredentials(const ConfigStd &config) const { + log_debug_printf(auths, + "\n******************************************\nDefault, " + "X.509 Authenticator: %s\n", + "Begin acquisition"); + + auto x509_credentials = std::make_shared(); + + // Set the expiration time of the certificate + time_t now = time(nullptr); + x509_credentials->not_before = now; + x509_credentials->not_after = now + (config.cert_validity_mins * 60); + + // If name is configured then use it instead of getting the username + if (!config.name.empty()) { + x509_credentials->name = config.name; + } else { + // Try to get username + char username[PVXS_X509_AUTH_USERNAME_MAX]; + if (osiGetUserName(username, PVXS_X509_AUTH_USERNAME_MAX) == osiGetUserNameSuccess) { + username[PVXS_X509_AUTH_USERNAME_MAX - 1] = '\0'; + x509_credentials->name = username; + } else { + x509_credentials->name = "nobody"; + } + } + + // If we've specified an organization then use it otherwise use the hostname or IP + if (!config.organization.empty()) { + x509_credentials->organization = config.organization; + } else { + // Get hostname or IP address (Organization) + char hostname[PVXS_X509_AUTH_HOSTNAME_MAX]; + if (!!gethostname(hostname, PVXS_X509_AUTH_HOSTNAME_MAX)) { + // If no hostname then try to get IP address + strcpy(hostname, getIPAddress().c_str()); + } + x509_credentials->organization = hostname; + } + + if (!config.organizational_unit.empty()) { + x509_credentials->organization_unit = config.organizational_unit; + } + + if (!config.country.empty()) { + x509_credentials->country = config.country; + } else { + x509_credentials->country = getCountryCode(); + } + + log_debug_printf(auths, "X.509 Credentials retrieved for: %s@%s\n", x509_credentials->name.c_str(), x509_credentials->organization.c_str()); + + return x509_credentials; +}; + +/** + * Create a PVStructure that corresponds to the ccr parameter of a certificate + * creation request. This request will be sent to the PVACMS through the default + * channel (PVAccess) and will be used to create the certificate. + * + * This default certificate creation request does nothing more than the base + * certificate creation request. + * + * @param credentials the credentials that describe the subject of the certificate + * @param key_pair the public/private key to be used in the certificate, only public key is used + * @param usage certificate usage + * @return A managed shared CertCreationRequest object. + */ +std::shared_ptr AuthStd::createCertCreationRequest(const std::shared_ptr &credentials, + const std::shared_ptr &key_pair, const uint16_t &usage) const { + auto cert_creation_request = Auth::createCertCreationRequest(credentials, key_pair, usage); + + return cert_creation_request; +}; + +/** + * @brief Verify the certificate creation request + * + * There is no verification for the basic credentials. Just return true. + * + * @param ccr the certificate creation request + * @param verify_fn the function to use to verify the certificate creation request + * @return true if the certificate creation request is valid + */ +bool AuthStd::verify(const Value ccr, std::function) const { return true; } +} // namespace certs +} // namespace pvxs + +using namespace pvxs::certs; + +/** + * @brief Main function for the authnstd tool + * + * @param argc the number of command line arguments + * @param argv the command line arguments + * @return the exit status + */ +int main(int argc, char *argv[]) { + pvxs::logger_config_env(); + + bool verbose{false}, retrieved_credentials{false}; + uint16_t cert_usage{pvxs::ssl::kForClient}; + std::string name, org, ou, country; + + try { + auto config = ConfigStd::fromEnv(); + std::shared_ptr key_pair; + + // Read commandline options + int exit_status; + + if ((exit_status = readOptions(config, argc, argv, verbose, cert_usage, name, org, ou, country))) { + return exit_status - 1; + } + + if (config.tls_cert_filename.empty()) { + std::cerr << "You must set at least one mandatory environment variables to create certificates: " << std::endl; + std::cerr << "\tEPICS_PVA_TLS_KEYCHAIN\t\t\tSet name and location of client certificate file (mandatory for clients)" << std::endl; + std::cerr << "\tEPICS_PVAS_TLS_KEYCHAIN\t\t\tSet name and location of server certificate file (mandatory for server)" << std::endl; + std::cerr << "\tEPICS_PVA_TLS_KEYCHAIN_PWD_FILE\t\tSet name and location of client certificate password file (optional)" << std::endl; + std::cerr << "\tEPICS_PVAS_TLS_KEYCHAIN_PWD_FILE\tSet name and location of server certificate password file (optional)" << std::endl; + std::cerr << "\tEPICS_PVA_TLS_PKEY\t\t\tSet name and location of client private key file (optional)" << std::endl; + std::cerr << "\tEPICS_PVAS_TLS_PKEY\t\t\tSet name and location of server private key file (optional)" << std::endl; + std::cerr << "\tEPICS_PVA_TLS_PKEY_PWD_FILE\t\tSet name and location of client private key password file (optional)" << std::endl; + std::cerr << "\tEPICS_PVAS_TLS_PKEY_PWD_FILE\t\tSet name and location of server private key password file (optional)" << std::endl; + return 10; + } + if (verbose) logger_level_set("pvxs.auth.std*", pvxs::Level::Info); + + // Standard authenticator + AuthStd authenticator; + + // If name overridden + if (!name.empty()) { + config.name = name; + } + // If org overridden + if (!org.empty()) { + config.organization = org; + } + // If org overridden + if (!ou.empty()) { + config.organizational_unit = ou; + } + // If org overridden + if (!country.empty()) { + config.country = country; + } + if (auto credentials = authenticator.getCredentials(config)) { + if (!ou.empty()) { + credentials->organization_unit = ou; + } + log_debug_printf(auths, "Credentials retrieved for: %s authenticator\n", authenticator.type_.c_str()); + retrieved_credentials = true; + + // Get key pair + try { + // Check if the key pair exists + if ( config.tls_private_key_filename.empty() ) { + key_pair = IdFileFactory::create(config.tls_cert_filename, config.tls_cert_password)->getKeyFromFile(); + } else { + key_pair = IdFileFactory::create(config.tls_private_key_filename, config.tls_private_key_password)->getKeyFromFile(); + } + } catch (std::exception &e) { + // Make a new key pair file + try { + log_warn_printf(auths, "%s\n", e.what()); + key_pair = authenticator.createKeyPair(config); + } catch (std::exception &e) { + throw(std::runtime_error(pvxs::SB() << "Error creating client key: " << e.what())); + } + } + + // Create a certificate creation request using the credentials and + // key pair + auto cert_creation_request = authenticator.createCertCreationRequest(credentials, key_pair, cert_usage); + + log_debug_printf(auths, "CCR created for: %s authentication type\n", authenticator.type_.c_str()); + + // Attempt to create a certificate with the certificate creation + // request + auto p12_pem_string = authenticator.processCertificateCreationRequest(cert_creation_request); + + // If the certificate was created successfully, + if (!p12_pem_string.empty()) { + log_debug_printf(auths, "Cert generated by PVACMS and successfully received: %s\n", p12_pem_string.c_str()); + + // Attempt to write the certificate and private key + // to a cert file protected by the configured password + auto file_factory = IdFileFactory::create((cert_usage ? config.tls_cert_filename : config.tls_cert_filename), config.tls_cert_password, + key_pair, nullptr, nullptr, "certificate", p12_pem_string); + file_factory->writeIdentityFile(); + + std::string from = std::ctime(&credentials->not_before); + std::string to = std::ctime(&credentials->not_after); + log_info_printf(auths, "%s\n", + (pvxs::SB() << "TYPE: " << ((authenticator.type_ == PVXS_DEFAULT_AUTH_TYPE) ? "basic" : authenticator.type_)).str().c_str()); + log_info_printf(auths, "%s\n", + (pvxs::SB() << "OUTPUT TO: " << config.tls_cert_filename + << (config.tls_private_key_filename.empty() or config.tls_private_key_filename == config.tls_cert_filename + ? "" + : " and " + config.tls_private_key_filename)) + .str() + .c_str()); + log_info_printf(auths, "%s\n", (pvxs::SB() << "NAME: " << credentials->name).str().c_str()); + log_info_printf(auths, "%s\n", (pvxs::SB() << "ORGANIZATION: " << credentials->organization).str().c_str()); + log_info_printf(auths, "%s\n", (pvxs::SB() << "ORGANIZATIONAL UNIT: " << credentials->organization_unit).str().c_str()); + log_info_printf(auths, "%s\n", (pvxs::SB() << "COUNTRY: " << credentials->country).str().c_str()); + log_info_printf(auths, "%s\n", + (pvxs::SB() << "VALIDITY: " << from.substr(0, from.size() - 1) << " to " << to.substr(0, to.size() - 1)).str().c_str()); + log_info_printf(auths, "--------------------------------------%s", "\n"); + + // Create the root certificate if it is not already there so + // that the user can trust it + if (file_factory->writeRootPemFile(p12_pem_string)) { + return CertAvailability::OK; + } else { + return CertAvailability::ROOT_CERT_INSTALLED; + } + } + } + } catch (std::exception &e) { + if (retrieved_credentials) log_warn_printf(auths, "%s\n", e.what()); + } + return 0; +} diff --git a/certs/authn/std/authnstd.h b/certs/authn/std/authnstd.h new file mode 100644 index 000000000..785115d18 --- /dev/null +++ b/certs/authn/std/authnstd.h @@ -0,0 +1,105 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +/** + * @file Defines the Default Authenticator. + * + * Provides class to encapsulate the Default Authenticator and defines custom + * credentials for use with the authenticator. + * + * The default authenticator uses the hostname, and the username unless a + * process name is provided in configuration, in which case it replaces the + * username. + * + * ZERO TRUST: + * For this, the Default Authenticator, there is nothing that the PVACMS can do + * to verify the authenticity of the information contained in the credentials we + * generate except for verification of the IP address from which the request + * came, and even then, when the request comes via a gateway, this is not + * possible. Other authenticators have their credentials verified in the PVACMS + * so it can be sure of the claims of the certificate it is signing. + * + * IT IS THEREFORE THE RESPONSIBILITY OF THE ADMINISTRATOR OF THE PVACMS + * TO IMPLEMENT POLICIES THAT ENSURE THAT ONLY AUTHORISED CLIENTS + * (PVA CLIENTS AND SERVERS) CAN GET CERTIFICATES. + * + * The PVACMS provides facilities for maintaining a whitelist, and for + * generating keys to be added to the certificate creation request (as the name) + * to somewhat validate the credentials. + * + * For clients the security implications are less than for servers. The + * requirement is mainly to simply identify unique users, rather than to verify + * that they are who they say they are. + * + * FOR SERVERS, IT IS RECOMMENDED THAT THEY ARE CONFIGURED MANUALLY THE FIRST + * TIME AND THAT FROM THEN ON THEY ARE AUTOMATICALLY RENEWED BEFORE THE + * EXPIRATION OF THEIR CERTIFICATES. + */ + +#ifndef PVXS_AUTH_DEFAULT_H +#define PVXS_AUTH_DEFAULT_H + +#include +#include +#include + +#include +#include + +#include "auth.h" +#include "certfactory.h" +#include "configstd.h" +#include "ownedptr.h" +#include "security.h" + +#define PVXS_X509_AUTH_DEFAULT_VALIDITY_S (static_cast(365.25 * 24 * 60 * 60) / 2) // Half a year +#define PVXS_X509_AUTH_HOSTNAME_MAX 1024 +#define PVXS_X509_AUTH_USERNAME_MAX 256 + +namespace pvxs { +namespace certs { + +/** + * The subclass of Credentials that contains the DefaultAuth specific + * identification object + */ +struct DefaultCredentials : public Credentials {}; + +/** + * @class DefaultAuth + * @brief The DefaultAuth class provides default authentication + * functionality that simply uses an X.509 certificate without any site specific + * authentication. + * + * It inherits from the Auth base class. + * + * The DefaultAuth MUST NOT be registered with the CertFactory + * using the REGISTER_AUTHENTICATOR() macro. + * + * The DefaultAuth class implements the getCredentials() and + * createCertCreationRequest() methods. The getCredentials() method returns the + * credentials containing the subject name to be used in the certificate The + * createCertCreationRequest() method creates a signed certificate using the + * provided credentials. + */ +class AuthStd : public Auth { + public: + // Constructor + AuthStd() : Auth(PVXS_DEFAULT_AUTH_TYPE, {}) {}; + ~AuthStd() override = default; + + std::shared_ptr getCredentials(const ConfigStd &config) const override; + + std::shared_ptr createCertCreationRequest(const std::shared_ptr &credentials, const std::shared_ptr &key_pair, + const uint16_t &usage) const override; + + bool verify(const Value ccr, std::function signature_verifier) const override; +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_AUTH_DEFAULT_H diff --git a/certs/authn/std/configstd.cpp b/certs/authn/std/configstd.cpp new file mode 100644 index 000000000..de6ad129f --- /dev/null +++ b/certs/authn/std/configstd.cpp @@ -0,0 +1,88 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "configstd.h" + +DEFINE_LOGGER(cfg, "pvxs.certs.cfg"); + +namespace pvxs { +namespace certs { + +void ConfigStd::fromStdEnv(const std::map &defs) { + PickOne pickone{defs, true}; + + // EPICS_AUTH_STD_CERT_VALIDITY_MINS + if (pickone({"EPICS_AUTH_STD_CERT_VALIDITY_MINS"})) { + try { + cert_validity_mins = parseTo(pickone.val); + } catch (std::exception &e) { + log_err_printf(cfg, "%s invalid validity minutes : %s", pickone.name.c_str(), e.what()); + } + } + + // EPICS_AUTH_STD_NAME + if (pickone({"EPICS_PVA_AUTH_STD_NAME"})) { + name = server_name = pickone.val; + } + + // EPICS_AUTH_STD_ORG + if (pickone({"EPICS_PVA_AUTH_STD_ORG"})) { + organization = server_organization = pickone.val; + } + + // EPICS_AUTH_STD_ORG_UNIT + if (pickone({"EPICS_PVA_AUTH_STD_ORG_UNIT"})) { + organizational_unit = server_organizational_unit = pickone.val; + } + + // EPICS_AUTH_STD_COUNTRY + if (pickone({"EPICS_PVA_AUTH_STD_COUNTRY"})) { + country = server_country = pickone.val; + } + + // EPICS_AUTH_STD_NAME + if (pickone({"EPICS_PVAS_AUTH_STD_NAME"})) { + server_name = pickone.val; + } + + // EPICS_AUTH_STD_ORG + if (pickone({"EPICS_PVAS_AUTH_STD_ORG"})) { + server_organization = pickone.val; + } + + // EPICS_AUTH_STD_ORG_UNIT + if (pickone({"EPICS_PVAS_AUTH_STD_ORG_UNIT"})) { + server_organizational_unit = pickone.val; + } + + // EPICS_AUTH_STD_COUNTRY + if (pickone({"EPICS_PVAS_AUTH_STD_COUNTRY"})) { + server_country = pickone.val; + } + + // EPICS_PVAS_TLS_KEYCHAIN + if (pickone({"EPICS_PVAS_TLS_KEYCHAIN"})) { + ensureDirectoryExists(tls_srv_cert_filename = pickone.val); + } + + // EPICS_PVAS_TLS_KEYCHAIN + if (pickone({"EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE"})) { + tls_srv_cert_password = getFileContents(pickone.val); + } + + // EPICS_PVAS_TLS_PKEY + if (pickone({"EPICS_PVAS_TLS_PKEY"})) { + ensureDirectoryExists(tls_srv_private_key_filename = pickone.val); + } + + // EPICS_PVAS_TLS_PKEY_PWD_FILE + if (pickone({"EPICS_PVAS_TLS_PKEY_PWD_FILE"})) { + tls_srv_private_key_password = getFileContents(pickone.val); + } +} + +} // namespace certs +} // namespace pvxs diff --git a/certs/authn/std/configstd.h b/certs/authn/std/configstd.h new file mode 100644 index 000000000..5276e3371 --- /dev/null +++ b/certs/authn/std/configstd.h @@ -0,0 +1,66 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CONFIGSTD_H_ +#define PVXS_CONFIGSTD_H_ + +#include + +#include +#include + +#include "ownedptr.h" + +namespace pvxs { +namespace certs { + +class ConfigStd : public pvxs::client::Config { + public: + ConfigStd& applyEnv() { + pvxs::client::Config::applyEnv(true, CLIENT); + return *this; + } + + static inline ConfigStd fromEnv() { + auto config = ConfigStd{}.applyEnv(); + config.fromStdEnv(std::map()); + return config; + } + + /** + * @brief The number of minutes from now after which the new certificate being created should expire. + * + * Use this to set the default validity for certificates + * generated from basic credentials. + */ + uint32_t cert_validity_mins = 43200; + + /** + * @brief Value will be used as the device name when an EPICS agent + * is determining basic credentials instead of the hostname as + * the principal + */ + std::string name; + std::string organization; + std::string organizational_unit; + std::string country; + + std::string server_name; + std::string server_organization; + std::string server_organizational_unit; + std::string server_country; + + std::string tls_srv_cert_filename; + std::string tls_srv_private_key_filename; + std::string tls_srv_cert_password; + std::string tls_srv_private_key_password; + + void fromStdEnv(const std::map& defs); +}; + +} // namespace certs +} // namespace pvxs +#endif // PVXS_CONFIGSTD_H_ diff --git a/certs/certfactory.cpp b/certs/certfactory.cpp new file mode 100644 index 000000000..23683039a --- /dev/null +++ b/certs/certfactory.cpp @@ -0,0 +1,585 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "certfactory.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "openssl.h" +#include "osiFileName.h" +#include "ownedptr.h" +#include "security.h" +#include "utilpvt.h" + +namespace pvxs { +namespace certs { + +DEFINE_LOGGER(certs, "pvxs.certs.cms"); + +/** + * Creates a new X.509 certificate from scratch. It uses the provided public + * key from the key pair and sets all the appropriate fields based on usage. + * It then signs the certificate with the issuer's private key if it is + * specified otherwise it uses the provided private key (self signed). + * + * @return a unique pointer to an X.509 certificate + */ +ossl_ptr CertFactory::create() { + // 1. Create an empty certificate + ossl_ptr certificate(X509_new()); + + // 2. Determine issuer: If no issuer then self sign, or specify cert & key + if (!issuer_certificate_ptr_) { + issuer_certificate_ptr_ = certificate.get(); + issuer_pkey_ptr_ = key_pair_->pkey.get(); + issuer_chain_ptr_ = nullptr; + } else if (!issuer_pkey_ptr_) { + throw std::runtime_error("Issuer' private key not provided for signing the certificate"); + } + + // 3. Set the certificate version to 2 (X.509 v3 - 0 based) + auto cert_version = 2; + if (X509_set_version(certificate.get(), cert_version) != 1) { + throw std::runtime_error("Failed to set certificate version."); + } + log_debug_printf(certs, "Set Cert Version: %d\n", cert_version + 1); + + // 4. Set the public key of the certificate using the provided key pair + if (X509_set_pubkey(certificate.get(), key_pair_->getPublicKey().get()) != 1) { + throw std::runtime_error("Failed to set public key in certificate."); + } + log_debug_printf(certs, "Public Key: %s\n", ""); + + // 5. Add an entry in the certificate's subject name using the common name + setSubject(certificate); + + // 6. Set the issuer symbolic name + if (X509_set_issuer_name(certificate.get(), X509_get_subject_name(issuer_certificate_ptr_)) != 1) { + throw std::runtime_error("Failed to set issuer name."); + } + log_debug_printf(certs, "Issuer Name: %s\n", ""); + + // 7. Set the validity period for the certificate using the not_before and + // not_after times. + setValidity(certificate); + + // 8. Set the serial number + setSerialNumber(certificate); + + // 9. Add several extensions to the certificate + addExtensions(certificate); + + // 10. Set the authority key identifier appropriately + addExtension(certificate, NID_authority_key_identifier, "keyid:always,issuer:always"); + + // 11. Add EPICS subscription status subscription extension, if required and is not CMS itself + if (cert_status_subscription_required_ && !IS_USED_FOR_(usage_, ssl::kForCMS)) { + auto issuerId = CertStatus::getIssuerId(issuer_certificate_ptr_); + addCustomExtensionByNid(certificate, ossl::SSLContext::NID_PvaCertStatusURI, CertStatus::makeStatusURI(issuerId, serial_)); + } + + // 12. Create cert chain from issuer's chain and issuer's cert + if (issuer_chain_ptr_) { + // Fill with issuer chain certificates if supplied + int num_certs = sk_X509_num(issuer_chain_ptr_); + log_debug_printf(certs, "Creating Certificate Chain with %d entries\n", num_certs + 1); + for (int i = 0; i < num_certs; ++i) { + if (sk_X509_push(certificate_chain_.get(), sk_X509_value(issuer_chain_ptr_, i)) != 1) { + throw std::runtime_error(SB() << "Failed create certificate chain for new certificate"); + } + } + // Add the issuer's certificate too + if (sk_X509_push(certificate_chain_.get(), issuer_certificate_ptr_) != 1) { + throw std::runtime_error(SB() << "Failed add issuer certificate to certificate chain"); + } + } else + log_debug_printf(certs, "Creating %s Certificate Chain\n", "*EMPTY*"); + + // 13. Sign the certificate with the private key of the issuer + if (!X509_sign(certificate.get(), issuer_pkey_ptr_, EVP_sha256())) { + throw std::runtime_error(SB() << "Failed to sign the certificate"); + } + log_debug_printf(certs, "Certificate: %s\n", ""); + + // Set the subject key identifier field + set_skid(certificate); + + return certificate; +} + +/* +std::string CertFactory::sign(const ossl_ptr &pkey, const std::string &data) { + ossl_ptr message_digest_context(EVP_MD_CTX_new()); + assert(message_digest_context.get() != nullptr); + + const EVP_MD *message_digest = EVP_sha256(); + assert(message_digest != nullptr); + + assert(EVP_DigestSignInit(message_digest_context.get(), nullptr, message_digest, nullptr, pkey.get()) == 1); + assert(EVP_DigestSignUpdate(message_digest_context.get(), data.c_str(), data.size()) == 1); + + size_t len = 0; + assert(EVP_DigestSignFinal(message_digest_context.get(), nullptr, &len) == 1); + + std::string signature(len, '\0'); + assert(EVP_DigestSignFinal(message_digest_context.get(), reinterpret_cast(&signature[0]), &len) == + 1); + signature.resize(len); + + return signature; +} +*/ + +/* +bool CertFactory::verifySignature(const ossl_ptr &pkey, const std::string &data, + const std::string &signature) { + const ossl_ptr message_digest_context(EVP_MD_CTX_new()); + assert(message_digest_context.get() != nullptr); + + const EVP_MD *message_digest = EVP_sha256(); + assert(message_digest != nullptr); + + assert(EVP_DigestVerifyInit(message_digest_context.get(), nullptr, message_digest, nullptr, pkey.get()) == 1); + assert(EVP_DigestVerifyUpdate(message_digest_context.get(), data.c_str(), data.size()) == 1); + + if (EVP_DigestVerifyFinal(message_digest_context.get(), reinterpret_cast(&signature[0]), + signature.size()) == 1) { + return true; + } else { + return false; + } +} +*/ + +/** + * Set the subject of the provided certificate. + * + * @param certificate A pointer to a certificate + */ +void CertFactory::setSubject(const ossl_ptr &certificate) { + auto subject_name(X509_get_subject_name(certificate.get())); + if (subject_name) { + if (X509_NAME_add_entry_by_txt(subject_name, "CN", MBSTRING_ASC, reinterpret_cast(name_.c_str()), -1, -1, 0) != 1) { + throw std::runtime_error(SB() << "Failed to set common name in certificate subject: " << name_); + } + log_debug_printf(certs, "Common Name: %s\n", name_.c_str()); + if (!country_.empty() && + X509_NAME_add_entry_by_txt(subject_name, "C", MBSTRING_ASC, reinterpret_cast(country_.c_str()), -1, -1, 0) != 1) { + throw std::runtime_error(SB() << "Failed to set country in certificate subject: " << name_); + } + log_debug_printf(certs, "Country: %s\n", country_.c_str()); + if (!org_.empty() && + X509_NAME_add_entry_by_txt(subject_name, "O", MBSTRING_ASC, reinterpret_cast(org_.c_str()), -1, -1, 0) != 1) { + throw std::runtime_error(SB() << "Failed to set org in certificate subject: " << name_); + } + log_debug_printf(certs, "Organization: %s\n", org_.c_str()); + if (!org_unit_.empty() && + X509_NAME_add_entry_by_txt(subject_name, "OU", MBSTRING_ASC, reinterpret_cast(org_unit_.c_str()), -1, -1, 0) != 1) { + throw std::runtime_error(SB() << "Failed to set country in certificate subject: " << name_); + } + log_debug_printf(certs, "Organizational Unit: %s\n", org_unit_.c_str()); + } +} + +/** + * Set the validity of the given certificate. The validity is set by setting + * the. not before and not after times using the provided parameters. + * + * @param certificate The certificate whose validity is to be set + */ +void CertFactory::setValidity(const ossl_ptr &certificate) const { + auto before = StatusDate::toAsn1_Time(not_before_); + auto after = StatusDate::toAsn1_Time(not_after_); + + if (X509_set1_notBefore(certificate.get(), before.get()) != 1) { + throw std::runtime_error("Failed to set validity start time in certificate."); + } + log_debug_printf(certs, "Not before: %s", std::ctime(¬_before_)); + + if (X509_set1_notAfter(certificate.get(), after.get()) != 1) { + throw std::runtime_error("Failed to set validity end time in certificate."); + } + log_debug_printf(certs, "Not after: %s", std::ctime(¬_after_)); +} + +/** + * Set the given certificate's serial number. The serial number should + * be unique for each certificate authority. + * + * @param certificate The certificate whose serial number is to be + */ +void CertFactory::setSerialNumber(const ossl_ptr &certificate) { // + ossl_ptr serial_number(ASN1_INTEGER_new()); + if (ASN1_INTEGER_set_uint64(serial_number.get(), serial_) != 1) { + throw std::runtime_error("Failed to create certificate serial number."); + } + if (X509_set_serialNumber(certificate.get(), serial_number.get()) != 1) { + throw std::runtime_error("Failed to set certificate serial number."); + } + log_debug_printf(certs, "Serial Number: %llu\n", serial_); +} + +/** + * To set all of the required extensions in this certificate. + * For a certificate to be valid, many certificates are mandatory. + * This function sets all of the mandatory extensions based on the + * specified usage of the certificate. + * + * @param certificate The certificate whose extensions are to be set + */ +void CertFactory::addExtensions(const ossl_ptr &certificate) { + // Subject Key Identifier + addExtension(certificate, NID_subject_key_identifier, "hash", certificate.get()); + + // Basic Constraints + auto basic_constraint((IS_USED_FOR_(usage_, ssl::kForCa) ? "critical,CA:TRUE" : "CA:FALSE")); + addExtension(certificate, NID_basic_constraints, basic_constraint); + + // Key usage + std::string usage; + if (IS_USED_FOR_(usage_, ssl::kForIntermediateCa)) { + usage = "digitalSignature,cRLSign,keyCertSign"; + } else if (IS_USED_FOR_(usage_, ssl::kForCa)) { + usage = "cRLSign,keyCertSign"; + } else if (IS_FOR_A_SERVER_(usage_)) { + usage = "digitalSignature,keyEncipherment"; + } else { + usage = "digitalSignature"; + } + if (!usage.empty()) { + addExtension(certificate, NID_key_usage, usage.c_str()); + } + + // Extended Key Usage: conditionally set based on `usage_` + std::string extended_usage; + if (IS_USED_FOR_(usage_, ssl::kForClientAndServer)) { + extended_usage = "clientAuth,serverAuth"; + } else if (IS_USED_FOR_(usage_, ssl::kForClient)) { + extended_usage = "clientAuth"; + } else if (IS_USED_FOR_(usage_, ssl::kForServer)) { + extended_usage = "serverAuth"; + } else if (IS_USED_FOR_(usage_, ssl::kForIntermediateCa)) { + extended_usage = "serverAuth,clientAuth,OCSPSigning"; + } else if (IS_USED_FOR_(usage_, ssl::kForCMS)) { + extended_usage = "serverAuth,OCSPSigning"; + } + if (!extended_usage.empty()) { + addExtension(certificate, NID_ext_key_usage, extended_usage.c_str()); + } +} + +/** + * Add an extension to certificate. + * + * Each NID_* has a corresponding const X509V3_EXT_METHOD + * in a crypto/x509/v3_*.c which defines the expected type of the void* value + * arg. + * + * NID_subject_key_identifier <-> ASN1_OCTET_STRING + * NID_authority_key_identifier <-> AUTHORITY_KEYID + * NID_basic_constraints <-> BASIC_CONSTRAINTS + * NID_key_usage <-> ASN1_BIT_STRING + * NID_ext_key_usage <-> EXTENDED_KEY_USAGE + * + * Use X509V3_CTX automates building these values in the correct way, + * and then calls low level X509_add1_ext_i2d() + * + * see also "man x509v3_config" for explanation of "expr" string. + */ +void CertFactory::addExtension(const ossl_ptr &certificate, int nid, const char *value, const X509 *subject) { + if (!value) { + throw std::invalid_argument("Value for the extension cannot be null."); + } + + X509V3_CTX context; + X509V3_set_ctx_nodb(&context); + X509V3_set_ctx(&context, const_cast(issuer_certificate_ptr_), const_cast(subject), nullptr, nullptr, 0); + + ossl_ptr extension(X509V3_EXT_conf_nid(nullptr, &context, nid, value), false); + if (!extension) { + unsigned long err = ERR_get_error(); + char err_msg[256]; + ERR_error_string_n(err, err_msg, sizeof(err_msg)); + throw std::runtime_error(SB() << "Failed to set certificate extension: " << err_msg); + } + + if (X509_add_ext(certificate.get(), extension.get(), -1) != 1) { + throw std::runtime_error("Failed to add certificate extension"); + } + log_debug_printf(certs, "Extension [%*d]: %-*s = \"%s\"\n", 3, nid, 32, nid2String(nid), value); +} + +/** + * Add a string extension by NID to certificate. + * + */ +void CertFactory::addCustomExtensionByNid(const ossl_ptr &certificate, int nid, std::string value, const X509 *issuer_certificate_ptr) { + char err_msg[256]; + X509V3_CTX context; + X509V3_set_ctx_nodb(&context); + X509V3_set_ctx(&context, const_cast(issuer_certificate_ptr), certificate.get(), nullptr, nullptr, 0); + + // Construct the string value using ASN1_STRING with IA5String type + ossl_ptr string_data(ASN1_IA5STRING_new(), false); + if (!string_data) { + throw std::runtime_error("Adding custom extension: Failed to create ASN1_IA5STRING object"); + } + + // Set the string data using IA5STRING + if (!ASN1_STRING_set(string_data.get(), value.c_str(), value.size())) { + unsigned long err = ERR_get_error(); + ERR_error_string_n(err, err_msg, sizeof(err_msg)); + throw std::runtime_error(SB() << "Adding custom extension: Failed to set ASN1_STRING: " << err_msg); + } + + // Create a new extension using your smart pointer + ossl_ptr ext(X509_EXTENSION_create_by_NID(nullptr, nid, false, string_data.get()), false); + if (!ext) { + unsigned long err = ERR_get_error(); + ERR_error_string_n(err, err_msg, sizeof(err_msg)); + throw std::runtime_error(SB() << "Adding custom extension: Failed to create X509_EXTENSION: " << err_msg); + } + + // Add the extension to the certificate + if (!X509_add_ext(certificate.get(), ext.get(), -1)) { + unsigned long err = ERR_get_error(); + ERR_error_string_n(err, err_msg, sizeof(err_msg)); + throw std::runtime_error(SB() << "Failed to add X509_EXTENSION to certificate: " << err_msg); + } + + log_debug_printf(certs, "Extension [%*d]: %-*s = \"%s\"\n", 3, nid, 32, nid2String(nid), value.c_str()); +} + +void CertFactory::addCustomExtensionByNid(const ossl_ptr &certificate, int nid, std::string value) { + addCustomExtensionByNid(certificate, nid, value, issuer_certificate_ptr_); +} + +/** + * This function determines the location of the certificate directory. + * + * In openssl installations there is a directory that contains the PEM files + * that are the filesystem representation of the certificate. When we + * automatically create a root CA certificate, we place the new certificate in + * this folder. + * + * An administrator must trust all the root certificates it finds in this + * location before they are accepted. + * + * @return location of certs directory + */ +std::string CertFactory::getCertsDirectory() { + // Get openssl directory + const char *openssl_dir = OpenSSL_version(OPENSSL_DIR); + + // Construct the certs directory path in an operating specific way + std::string dir(openssl_dir); + + // certs dir returns "OPENSSLDIR: \"/opt/homebrew/etc/openssl@3\"" + auto begin = dir.find("\"") + 1; + auto end = dir.find("\"", begin); + dir = std::string(dir.c_str(), begin, end - begin); + std::string certs_dir = SB() << dir << OSI_PATH_SEPARATOR << "certs"; + + return certs_dir; +} + +/** + * @brief HELPER FUNCTION: Create a new managed Basic Input Output + * object that can be used to output in various forms, throw for errors + * + * @return new managed BIO object + */ +ossl_ptr CertFactory::newBio() { + ERR_clear_error(); + ossl_ptr bio(BIO_new(BIO_s_mem()), false); + if (!bio) { + throw std::runtime_error(SB() << "Error: Failed to create bio for output: " << getError()); + } + return bio; +} + +/** + * @brief HELPER FUNCTION: Output the Basic Input Ouptut object as a string + * + * return string representation of the BIO object + */ +std::string CertFactory::bioToString(const ossl_ptr &bio) { + BUF_MEM + *bptr; // to hold pointer to data in the BIO object. + BIO_get_mem_ptr(bio.get(), &bptr); // set to point into BIO object + + // Create a std::string from the BIO + std::string result(bptr->data, bptr->length); + + return result; +} + +/** + * HELPER FUNCTION: Add the given certificate to the given Basic Input Output + * object + * + * @param bio the BIO to add the cert to + * @param cert the certificate to add to the BIO stream + */ +void CertFactory::writeCertToBio(const ossl_ptr &bio, const ossl_ptr &cert) { + ERR_clear_error(); + if (!PEM_write_bio_X509(bio.get(), cert.get())) { + throw std::runtime_error(SB() << "Error writing certificate to BIO: " << getError()); + } +} + +/** + * HELPER FUNCTION: Add the given certificate stack to the given Basic Input + * Output object + * + * @param bio the BIO to add the cert to + * @param certs the certificate stack to add to the BIO stream + */ +void CertFactory::writeCertsToBio(const ossl_ptr &bio, const STACK_OF(X509) * certs) { + if (certs) { + ERR_clear_error(); + // Get number of certificates in the stack + int count = sk_X509_num(certs); + + for (int i = 0; i < count; i++) { + if (!PEM_write_bio_X509(bio.get(), sk_X509_value(certs, i))) { + std::cout << "STACK ERROR: " << getError() << std::endl; + throw std::runtime_error(SB() << "Error writing certificate to BIO: " << getError()); + } + } + } +} + +/** + * @brief Write a PKCS12 object into the given BIO stream in the PEM format . + * + * This function writes the content of a PKCS12 object to the specified BIO + * stream. Notably it copies: + * 1. The main certificate + * 2. Certificate chain: + * - default: copy all the certs in the chain, including the root + * certificate. + * - `root_only` true: only the root certificate is copied + * + * Usage: + * Use case 1: Create a CA certificate: A CA Certificate needs to contain + * the certificate as well as the whole certificate chain and the root + * certificate (self signed in our case) + * + * Use case 2: Create a + * + * @param bio The BIO output stream to save the PEM file content to. + * @param p12 The PKCS12 content to be written. + * @param root_only Flag indicating whether only the root certificate should be + * included. + */ +void CertFactory::writeP12ToBio(const ossl_ptr &bio, const ossl_ptr &p12, std::string password, const bool root_only) { + ossl_ptr ca; + ossl_ptr cert; + + if (!PKCS12_parse(p12.get(), password.c_str(), NULL, cert.acquire(), ca.acquire())) { + throw std::runtime_error("Error: Parsing PKCS#12 failed. \n"); + } + + // Write the certificates to the PEM output + PEM_write_bio_X509(bio.get(), cert.get()); + + if (ca && sk_X509_num(ca.get()) > 0) { + auto count = sk_X509_num(ca.get()); + for (int i = root_only ? count - 1 : 0; i < count; i++) { + PEM_write_bio_X509(bio.get(), sk_X509_value(ca.get(), i)); + } + } +} + +std::string CertFactory::certAndP12ToPemString(const ossl_ptr &p12, const ossl_ptr &new_cert, std::string password) { + auto bio = newBio(); + + // Write the newly created certificate and the PKCS12 certificates to the + // output + writeCertToBio(bio, new_cert); + writeP12ToBio(bio, p12, password); + + return bioToString(bio); +} + +std::string CertFactory::p12ToPemString(ossl_ptr &p12, std::string password) { + auto bio = newBio(); + + // Write the PKCS12 contents to the output + writeP12ToBio(bio, p12, password); + + return bioToString(bio); +} + +bool CertFactory::isSelfSigned(X509 *cert) { + /* Get the issuer name. */ + X509_NAME *issuer_name = X509_get_issuer_name(cert); + + /* Get the subject name. */ + X509_NAME *subject_name = X509_get_subject_name(cert); + + /* Compare the two names. */ + return (X509_NAME_cmp(issuer_name, subject_name) == 0); +} + +std::string CertFactory::rootCertToString(ossl_ptr &p12, std::string password) { + auto bio = newBio(); + + // Write the PKCS12 certificates to the output + writeP12ToBio(bio, p12, password, true); + + return bioToString(bio); +} + +std::string CertFactory::certAndCasToPemString(const ossl_ptr &cert, const STACK_OF(X509) * ca) { + auto bio = newBio(); + + writeCertToBio(bio, cert); + writeCertsToBio(bio, ca); + + return bioToString(bio); +} + +void CertFactory::set_skid(ossl_ptr &certificate) { + int pos = -1; + std::stringstream skid_ss; + + pos = X509_get_ext_by_NID(certificate.get(), NID_subject_key_identifier, pos); + X509_EXTENSION *ex = X509_get_ext(certificate.get(), pos); + + ossl_ptr skid(reinterpret_cast(X509V3_EXT_d2i(ex)), false); + + if (skid != NULL) { + // Convert to hexadecimal string + for (int i = 0; i < skid->length; i++) { + skid_ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(skid->data[i]); + } + } + + skid_ = skid_ss.str(); +} + +} // namespace certs +} // namespace pvxs diff --git a/certs/certfactory.h b/certs/certfactory.h new file mode 100644 index 000000000..1ccca0ad5 --- /dev/null +++ b/certs/certfactory.h @@ -0,0 +1,253 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CERT_FACTORY_H +#define PVXS_CERT_FACTORY_H + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "certstatus.h" +#include "ownedptr.h" +#include "security.h" + +namespace pvxs { +namespace certs { + +#define PVXS_DEFAULT_AUTH_TYPE "x509" + +#define METHOD_STRING(type) (((type).compare(PVXS_DEFAULT_AUTH_TYPE) == 0) ? "default credentials" : ((type) + " credentials")) +#define NAME_STRING(name, org) name + (org.empty() ? "" : ("@" + (org))) + +/** + * @class CertFactory + * + * @brief Manages certificates and associated operations. + * + * This class provides methods for creating certificates, creating key + * pairs, and verifying certificates. + */ +class PVXS_API CertFactory { + public: + uint64_t serial_; + const std::shared_ptr key_pair_; + const std::string name_; + const std::string country_; + const std::string org_; + const std::string org_unit_; + const time_t not_before_; + const time_t not_after_; + const uint16_t usage_; + X509 *issuer_certificate_ptr_; // Will point to the issuer certificate when created + EVP_PKEY *issuer_pkey_ptr_; // Will point to the issuer private key when created + STACK_OF(X509) * issuer_chain_ptr_; // issuer cert chain + const ossl_shared_ptr certificate_chain_; + bool cert_status_subscription_required_; + std::string skid_; + certstatus_t initial_status_; + + /** + * @brief Constructor for CertFactory + * + * @param serial the serial number + * @param key_pair the key pair + * @param name the name + * @param country the country + * @param org the organization + * @param org_unit the organizational unit + * @param not_before the not before time + * @param not_after the not after time + * @param usage the usage + * @param cert_status_subscription_required whether certificate status subscription is required + * @param issuer_certificate_ptr the issuer certificate + * @param issuer_pkey_ptr the issuer private key + * @param issuer_chain_ptr the issuer certificate chain + * @param initial_status the initial status + * @param issuer_certificate_ptr the issuer certificate optional + * @param issuer_pkey_ptr the issuer private key optional + * @param issuer_chain_ptr the issuer certificate chain optional + * @param initial_status the initial status - defaults to VALID + */ + CertFactory(uint64_t serial, const std::shared_ptr &key_pair, const std::string &name, const std::string &country, const std::string &org, + const std::string &org_unit, time_t not_before, time_t not_after, const uint16_t &usage, bool cert_status_subscription_required = false, + X509 *issuer_certificate_ptr = nullptr, EVP_PKEY *issuer_pkey_ptr = nullptr, STACK_OF(X509) *issuer_chain_ptr = nullptr, + certstatus_t initial_status = VALID) + : serial_(serial), + key_pair_(key_pair), + name_(name), + country_(country), + org_(org), + org_unit_(org_unit), + not_before_(not_before), + not_after_(not_after), + usage_(usage), + issuer_certificate_ptr_(issuer_certificate_ptr), + issuer_pkey_ptr_(issuer_pkey_ptr), + issuer_chain_ptr_(issuer_chain_ptr), + certificate_chain_(sk_X509_new_null()), + initial_status_(initial_status) { + cert_status_subscription_required_ = cert_status_subscription_required; + }; + + ossl_ptr PVXS_API create(); + + static std::string PVXS_API certAndCasToPemString(const ossl_ptr &cert, const STACK_OF(X509) * ca); + + static std::string getCertsDirectory(); + + // static bool PVXS_API verifySignature(const ossl_ptr &pkey, const std::string &data, const std::string &signature); + + // static std::string sign(const ossl_ptr &pkey, const std::string &data); + + /** + * @brief Get the error string from the error queue + * @return the error string + */ + static inline std::string getError() { + unsigned long err; + std::string error_string; + std::string sep; + while ((err = ERR_get_error())) // get all error codes from the error queue + { + char buffer[256]; + ERR_error_string_n(err, buffer, sizeof(buffer)); + error_string += sep + buffer; + sep = ", "; + } + return error_string; + } + + static std::string bioToString(const ossl_ptr &bio); + static void addCustomExtensionByNid(const ossl_ptr &certificate, int nid, std::string value, const X509 *issuer_certificate_ptr); + + /** + * @brief Get the hash name of a certificate + * @param cert_path the path to the certificate + * @return the hash name + */ + static inline std::string getCertHashName(const std::string &cert_path) { + std::ifstream cert_file(cert_path, std::ios::binary); + if (!cert_file) { + throw std::runtime_error("Unable to open certificate file"); + } + + std::string cert_data((std::istreambuf_iterator(cert_file)), std::istreambuf_iterator()); + + ossl_ptr bio(BIO_new_mem_buf(cert_data.data(), cert_data.size()), false); + if (!bio) { + throw std::runtime_error("Failed to create BIO"); + } + + ossl_ptr cert(PEM_read_bio_X509_AUX(bio.get(), NULL, NULL, NULL), false); + if (!cert) { + throw std::runtime_error("Failed to read certificate"); + } + + unsigned long hash = X509_subject_name_hash(cert.get()); + + std::stringstream ss; + ss << std::hex << std::setfill('0') << std::setw(8) << hash << ".0"; + return ss.str(); + } + + /** + * @brief Create a symlink to a certificate + * @param cert_path the path to the certificate + * @return the path to the symlink + */ + static inline std::string createCertSymlink(const std::string &cert_path) { + std::string hash_name = getCertHashName(cert_path); + std::string dir_path; + size_t last_slash = cert_path.find_last_of("/\\"); + if (last_slash != std::string::npos) { + dir_path = cert_path.substr(0, last_slash + 1); + } + std::string symlink_path = dir_path + hash_name; + std::string target_path = cert_path.substr(last_slash + 1); + std::remove(symlink_path.c_str()); + +#ifdef _WIN32 + // Windows doesn't support symlinks easily, so we'll create a hard link + if (!CreateHardLinkA(symlink_path.c_str(), cert_path.c_str(), nullptr)) { + throw std::runtime_error("Failed to create hard link: " + std::to_string(GetLastError())); + } +#else + // UNIX-like systems + if (symlink(target_path.c_str(), symlink_path.c_str()) != 0) { + throw std::runtime_error("Failed to create symlink: " + std::string(strerror(errno))); + } +#endif + return hash_name; + } + + private: + /** + * @brief Convert a NID to a string + * @param nid the NID + * @return the string representation of the NID + */ + static inline const char *nid2String(int nid) { + switch (nid) { + case NID_subject_key_identifier: + return LN_subject_key_identifier; + case NID_key_usage: + return LN_key_usage; + case NID_basic_constraints: + return LN_basic_constraints; + case NID_authority_key_identifier: + return LN_authority_key_identifier; + case NID_ext_key_usage: + return LN_ext_key_usage; + default: + return "unknown"; + } + } + + static bool isSelfSigned(X509 *cert); + + void setSubject(const ossl_ptr &certificate); + + void setValidity(const ossl_ptr &certificate) const; + + void setSerialNumber(const ossl_ptr &certificate); + + void addExtensions(const ossl_ptr &certificate); + + void addExtension(const ossl_ptr &certificate, int nid, const char *value, const X509 *subject = nullptr); + + void addCustomExtensionByNid(const ossl_ptr &certificate, int nid, std::string value); + + static void writeCertToBio(const ossl_ptr &bio, const ossl_ptr &cert); + + static void writeCertsToBio(const ossl_ptr &bio, const STACK_OF(X509) * certs); + + static ossl_ptr newBio(); + + static void writeP12ToBio(const ossl_ptr &bio, const ossl_ptr &p12, std::string password, bool root_only = false); + + static std::string certAndP12ToPemString(const ossl_ptr &p12, const ossl_ptr &new_cert, std::string password); + + static std::string p12ToPemString(ossl_ptr &p12, std::string password); + + static std::string rootCertToString(ossl_ptr &p12, std::string password); + + void set_skid(ossl_ptr &certificate); +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_CERT_FACTORY_H diff --git a/certs/certfilefactory.cpp b/certs/certfilefactory.cpp new file mode 100644 index 000000000..87e4811fa --- /dev/null +++ b/certs/certfilefactory.cpp @@ -0,0 +1,242 @@ +#include "certfilefactory.h" + +#include +#include +#include + +#include // For RAND_seed + +#include + +#include + +#include "p12filefactory.h" +#include "pemfilefactory.h" + +namespace pvxs { +namespace certs { + +DEFINE_LOGGER(certs, "pvxs.certs.file"); + +/** + * @brief Backs up a file if it exists. + * + * This method creates a backup of the file by renaming it with a timestamp. + * + * @param filename The filename and path of the file to backup. + */ +void IdFileFactory::backupFileIfExists(const std::string& filename) { + std::fstream file(filename, std::ios_base::in); + if (!file.is_open()) + // File does not exist, return + return; + + file.close(); + + auto t = std::time(nullptr); + auto tm = *std::localtime(&t); + + std::ostringstream oss; + oss << std::put_time(&tm, "%y%m%d%H%M"); + + // new filename is {base filename}.{yy}{mm}{dd}{HH}{MM}.p12 + std::string extension = getExtension(filename); + std::string new_filename = filename.substr(0, filename.size() - 4) + "." + oss.str() + "." + extension; + + // Rename the file + std::rename(filename.c_str(), new_filename.c_str()); + + log_warn_printf(certs, "Cert file backed up: %s ==> %s\n", filename.c_str(), new_filename.c_str()); +} + +/** + * @brief Creates a certificate chain from a root certificate pointer. + * + * This method creates a certificate chain from a root certificate pointer. + * The chain is allocated and the root certificate is added to it. + * The chain will always have only one certificate in it. + * + * Use this function when you need to have a CA certificate chain but its a self signed + * certificate. + * + * @param chain The certificate chain reference that will be allocated and populated. + * @param root_cert_ptr The root certificate to add to the chain. + */ +void IdFileFactory::chainFromRootCertPtr(STACK_OF(X509) * &chain, X509* root_cert_ptr) { + if (!root_cert_ptr) { + throw std::runtime_error("Root certificate pointer is null"); + } + + chain = sk_X509_new_null(); + if (!chain) { + throw std::runtime_error("Unable to allocate space for certificate chain"); + } + + if (sk_X509_push(chain, root_cert_ptr) != 1) { + sk_X509_free(chain); + throw std::runtime_error("Unable to add root certificate to chain"); + } +} + +/** + * @brief Writes a root PEM file. + * + * This method writes a root PEM file. + * It is used to create the CA certificate file for agents that don't already have one. + * + * If it already exists it will be overwritten if overwrite is true. + * If a file is created then instructions on how to trust the certificate are printed to the log. + * + * @param pem_string The PEM string to write to the file. + * @param overwrite Whether to overwrite the file if it already exists. + * @return true if the file was created, false if it already exists. + */ +bool IdFileFactory::writeRootPemFile(const std::string& pem_string, const bool overwrite) { return PEMFileFactory::createRootPemFile(pem_string, overwrite); } + +/** + * @brief Gets the certificate data. + * + * This method gets the certificate data. This can only be called if a certificate is available after a call to writeIdentityFile. + * + * @param key_pair The key pair to include in the certificate data. + * @return The certificate data. + */ +CertData IdFileFactory::getCertData(const std::shared_ptr& key_pair) { + if (pem_string_.empty() && !cert_ptr_) { + throw std::runtime_error("No certificate data available"); + } + + ossl_ptr cert; + ossl_shared_ptr chain(sk_X509_new_null()); + if (!chain) { + throw std::runtime_error("Failed to create certificate chain"); + } + + if (!pem_string_.empty()) { + // Parse certificates from PEM string + ossl_ptr bio(BIO_new_mem_buf(pem_string_.data(), pem_string_.size()), false); + if (!bio) { + throw std::runtime_error("Failed to create BIO for PEM data"); + } + + // Read first certificate (the main cert) + cert.reset(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr)); + if (!cert) { + throw std::runtime_error("Failed to read certificate from PEM data"); + } + + // Read remaining certificates into chain + while (true) { + ossl_ptr chain_cert(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), false); + if (!chain_cert) { + ERR_clear_error(); // Clear EOF error + break; + } + if (sk_X509_push(chain.get(), chain_cert.get()) != 1) { + throw std::runtime_error("Failed to add certificate to chain"); + } + chain_cert.release(); // Ownership transferred to stack + } + } else { + // Use certificate pointers + cert.reset(X509_dup(cert_ptr_)); + if (!cert) { + throw std::runtime_error("Failed to duplicate certificate"); + } + + if (certs_ptr_) { + // Duplicate each certificate in the chain + for (int i = 0; i < sk_X509_num(certs_ptr_); i++) { + ossl_ptr int_cert(X509_dup(sk_X509_value(certs_ptr_, i)), false); + if (!int_cert || sk_X509_push(chain.get(), int_cert.get()) != 1) { + throw std::runtime_error("Failed to duplicate chain certificate"); + } + int_cert.release(); // Ownership transferred to stack + } + } + } + + return CertData(cert, chain, key_pair); +} + +cert_factory_ptr IdFileFactory::create(const std::string& filename, const std::string& password, const std::shared_ptr& key_pair, X509* cert_ptr, + STACK_OF(X509) * certs_ptr, const std::string& usage, const std::string& pem_string, bool certs_only) { + std::string ext = getExtension(filename); + if (ext == "p12" || ext == "pfx") { + if (certs_only) { + log_warn_printf(certs, "**No key**: For compatibility %s keychain files (.p12, .pfx) should contain a private key\n", usage.c_str()); + } + if (!pem_string.empty()) { + return make_factory_ptr(filename, password, key_pair, pem_string, certs_only); + } else if (!cert_ptr) { + return make_factory_ptr(filename, password, key_pair, certs_only); + } else { + return make_factory_ptr(filename, password, key_pair, cert_ptr, certs_ptr, certs_only); + } + } else if (ext == "pem" || ext == "crt" || ext == "key" || ext == "cer") { + if (!pem_string.empty()) { + return make_factory_ptr(filename, password, key_pair, pem_string, certs_only); + } else { + return make_factory_ptr(filename, password, key_pair, cert_ptr, certs_ptr, certs_only); + } + } + throw std::runtime_error(SB() << usage << ": Unsupported certificate file extension: " << (ext.empty() ? "" : ext)); +} + +/** + * @brief Creates a key pair. + * + * This method generates a new private key and a corresponding public key pair, + * + * @return a unique pointer to a managed KeyPair object. + */ +std::shared_ptr IdFileFactory::createKeyPair() { + // Create a new KeyPair object + auto key_pair = std::make_shared(); + + const int kKeySize = 2048; // Key size + const int kKeyType = EVP_PKEY_RSA; // Key type + + // Initialize the context for the key generation operation + ossl_ptr context(EVP_PKEY_CTX_new_id(kKeyType, nullptr), false); + if (!context) { + throw std::runtime_error("Failed to create EVP_PKEY_CTX"); + } + + // Initialize key generation context for RSA algorithm + if (EVP_PKEY_keygen_init(context.get()) != 1) { + throw std::runtime_error("Failed to initialize EVP_KEY context for key generation"); + } + + // Set the RSA key size for key generation + if (EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), kKeySize) != 1) { + throw std::runtime_error("Failed to set RSA key size for key generation"); + } + + // Generate the key pair + if (EVP_PKEY_keygen(context.get(), key_pair->pkey.acquire()) != 1) { + throw std::runtime_error("Failed to generate key pair"); + } + + // Create a memory buffer BIO for storing the public key + ossl_ptr bio_public(BIO_new(BIO_s_mem())); + + // Write the public key into the buffer + if (!PEM_write_bio_PUBKEY(bio_public.get(), key_pair->pkey.get())) { + throw std::runtime_error("Failed to write public key to BIO"); + } + + // Get the public key data as binary and store it in the buffer + char* bio_buffer_pub = nullptr; + long public_key_length = BIO_get_mem_data(bio_public.get(), &bio_buffer_pub); + + // Convert buffer containing public key data into std::string format + std::string public_key(bio_buffer_pub, public_key_length); + key_pair->public_key = public_key; + log_debug_printf(certs, "Key Pair Generated: %s\n", public_key.c_str()); + + return key_pair; +} + +} // namespace certs +} // namespace pvxs diff --git a/certs/certfilefactory.h b/certs/certfilefactory.h new file mode 100644 index 000000000..0f5904dfb --- /dev/null +++ b/certs/certfilefactory.h @@ -0,0 +1,159 @@ +#ifndef PVXS_CERT_FILE_FACTORY_H +#define PVXS_CERT_FILE_FACTORY_H + +#include +#include +#include + +#include + +#include + +#include "ownedptr.h" +#include "security.h" + +namespace pvxs { +namespace certs { + +// Forward declarations +class P12FileFactory; +class PEMFileFactory; +class IdFileFactory; + +// C++11 implementation of make_unique +template +std::unique_ptr make_factory_ptr(Args&&... args) { + return std::unique_ptr(new T(std::forward(args)...)); +} + +// CertData structure definition +struct CertData { + ossl_ptr cert; + ossl_shared_ptr ca; + std::shared_ptr key_pair; + + CertData(ossl_ptr& newCert, ossl_shared_ptr& newCa) : cert(std::move(newCert)), ca(newCa) {} + CertData(ossl_ptr& newCert, ossl_shared_ptr& newCa, std::shared_ptr key_pair) + : cert(std::move(newCert)), ca(newCa), key_pair(key_pair) {} +}; + +/** + * @brief The availability of a certificate file + * + * This is returned when when authentication daemons are trying to provision the configured certificate files. + * - `NOT_AVAILABLE` is returned if the file does not exist and can't be provisioned. + * - `ROOT_CERT_INSTALLED` is returned if the file exists or has been provisioned but + * the root CA certificate was downloaded and installed during the call. This signals to the caller + * the configured certificate will be unusable until the user trusts the root CA certificate. + * - `AVAILABLE` is returned if the file already exists. + * - `OK` is returned if the certificate file was provisioned and is ready for use. + */ +enum CertAvailability { + OK, + NOT_AVAILABLE, + ROOT_CERT_INSTALLED, + AVAILABLE, +}; + +typedef std::unique_ptr cert_factory_ptr; + +class IdFileFactory { + public: + /** + * @brief Creates a new CertFileFactory object. + * + * This method creates a new CertFileFactory object. + */ + static cert_factory_ptr create(const std::string& filename, const std::string& password = "", const std::shared_ptr& key_pair = nullptr, + X509* cert_ptr = nullptr, STACK_OF(X509) * certs_ptr = nullptr, const std::string& usage = "certificate", + const std::string& pem_string = "", bool certs_only = false); + + static cert_factory_ptr createReader(const std::string& filename, const std::string& password = "", const std::string& key_filename = "", + const std::string& key_password = "") { + auto cert_file_factory = create(filename, password); + if (!key_filename.empty()) cert_file_factory->key_file_ = create(key_filename, key_password); + + return cert_file_factory; + } + + virtual ~IdFileFactory() = default; + + /** + * @brief Writes the credentials file. + * + * This method writes an identity file which can be: + * - the private key + * - the X.509 certificate and CA chain + * - both + * The format (PKCS#12, or Base64-encoded ASCII) is determined by the filename extension. + */ + virtual void writeIdentityFile() = 0; + + /** + * @brief Gets the certificate data from the file. + * + * This method gets the certificate data from the file. + * The format (PKCS#12, or Base64-encoded ASCII) is determined by the filename extension. + */ + virtual CertData getCertDataFromFile() = 0; + + /** + * @brief Gets the key from the file. + * + * This method gets the key from the file. + * The format (PKCS#12, or Base64-encoded ASCII) is determined by the filename extension. + */ + virtual std::shared_ptr getKeyFromFile() = 0; + + /** + * @brief Creates a key pair. + * + * This method creates a key pair. Private key is generated and public key is extracted from the private key. + */ + static std::shared_ptr createKeyPair(); + + /** + * @brief Writes a root PEM file. + * + * This method writes a root PEM file + */ + bool writeRootPemFile(const std::string& pem_string, bool overwrite = false); + CertData getCertData(const std::shared_ptr& key_pair); + + protected: + IdFileFactory(const std::string& filename, const std::string& password = "", const std::shared_ptr& key_pair = nullptr, X509* cert_ptr = nullptr, + STACK_OF(X509) * certs_ptr = nullptr, const std::string& usage = "certificate", const std::string& pem_string = "", bool certs_only = false) + : filename_(filename), + password_(password), + key_pair_(key_pair), + cert_ptr_(cert_ptr), + certs_ptr_(certs_ptr), + usage_(usage), + pem_string_(pem_string), + certs_only_(certs_only) {} + + const std::string filename_{}; + std::string password_{}; + const std::shared_ptr key_pair_; + X509* cert_ptr_{nullptr}; + STACK_OF(X509) * certs_ptr_ { nullptr }; + const std::string usage_{}; + const std::string pem_string_{}; + const bool certs_only_{false}; + std::unique_ptr key_file_; + + static void backupFileIfExists(const std::string& filename); + static void chainFromRootCertPtr(STACK_OF(X509) * &chain, X509* root_cert_ptr); + static std::string getExtension(const std::string& filename) { + auto pos = filename.find_last_of('.'); + if (pos == std::string::npos) { + return ""; + } + return filename.substr(pos + 1); + } +}; + +} // namespace certs +} // namespace pvxs + +#endif diff --git a/certs/certstatusfactory.cpp b/certs/certstatusfactory.cpp new file mode 100644 index 000000000..09c31573c --- /dev/null +++ b/certs/certstatusfactory.cpp @@ -0,0 +1,235 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The Certificate status Factory. + * + */ + +#include "certstatusfactory.h" + +#include +#include +#include + +#include + +#include "configcms.h" + +namespace pvxs { +namespace certs { + +class CertStatusManager; + +/** + * @brief Creates and signs an OCSP response for a given certificate. + * + * This function takes in a certificate, certificate status, revocation time, CA certificate, + * CA private key, and CA chain as input parameters. It creates an OCSP_CERTID using the CA + * certificate and its serial number. Then it creates an OCSP request using the OCSP_CERTID. + * Next, it creates an OCSP basic response using the OCSP request, CA certificate, CA private key, + * CA chain, and certificate status. The function adds the status times to the OCSP basic response + * and serializes the response into a byte array. The byte array is then returned. + * + * @param cert The certificate. + * @param status The status of the certificate (PENDING_VALIDATION, VALID, EXPIRED, or REVOKED). + * @param this_status_update The status date of this status certification, normally now. + * @param predicated_revocation_time The time of revocation for the certificate if revoked. + * + * @see createOCSPCertId + * @see ocspResponseToBytes + */ +PVACertificateStatus CertStatusFactory::createPVACertificateStatus(const ossl_ptr& cert, certstatus_t status, StatusDate this_status_update, + StatusDate predicated_revocation_time) const { + return createPVACertificateStatus(getSerialNumber(cert), status, this_status_update, predicated_revocation_time); +} + +/** + * @brief Create a PVACertificateStatus for a given certificate, that involves creating a signed OCSP response . + * + * This function takes in a serial number, certificate status, revocation time, CA certificate, + * CA private key, and CA chain as input parameters. It creates an OCSP_CERTID using the CA + * certificate and serial number. Then it creates an OCSP request using the OCSP_CERTID. + * Next, it creates an OCSP basic response using the OCSP request, CA certificate, CA private key, + * CA chain, and certificate status. The function adds the status times to the OCSP basic response + * and serializes the response into a byte array. The byte array is then returned. + * + * @param serial The serial number of the certificate. + * @param status The status of the certificate (PENDING_VALIDATION, VALID, EXPIRED, or REVOKED). + * @param this_status_update The status date of this status certification, normally now. + * @param predicated_revocation_time The time of revocation for the certificate if revoked. + * + * @see createOCSPCertId + * @see ocspResponseToBytes + */ +PVACertificateStatus CertStatusFactory::createPVACertificateStatus(uint64_t serial, certstatus_t status, StatusDate this_status_update, + StatusDate predicated_revocation_time) const { + // Create OCSP response + ossl_ptr basic_resp(OCSP_BASICRESP_new()); + + // Set ASN1_TIME objects + auto status_valid_until_time = StatusDate(this_status_update.t + (cert_status_validity_mins_ * 60) + cert_status_validity_secs_); + const auto this_update = this_status_update.toAsn1_Time(); + const auto next_update = status_valid_until_time.toAsn1_Time(); + StatusDate revocation_time_to_use = (time_t)0; // Default to 0 + + // Determine the OCSP status and revocation time + ocspcertstatus_t ocsp_status; + switch (status) { + case VALID: + ocsp_status = OCSP_CERTSTATUS_GOOD; + break; + case REVOKED: + ocsp_status = OCSP_CERTSTATUS_REVOKED; + revocation_time_to_use = predicated_revocation_time; + break; + default: + ocsp_status = OCSP_CERTSTATUS_UNKNOWN; + break; + } + auto revocation_asn1_time = revocation_time_to_use.toAsn1_Time(); + + // Create OCSP_CERTID + auto cert_id = createOCSPCertId(serial); + + // Add the status to the OCSP response + if (!OCSP_basic_add1_status(basic_resp.get(), cert_id.get(), ocsp_status, 0, revocation_asn1_time.get(), this_update.get(), next_update.get())) { + throw std::runtime_error(SB() << "Failed to add status to OCSP response: " << getError()); + } + + // Adding the CA chain to the response + if (ca_chain_) { + for (int i = 0; i < sk_X509_num(ca_chain_.get()); i++) { + X509* cert = sk_X509_value(ca_chain_.get(), i); + OCSP_basic_add1_cert(basic_resp.get(), cert); + } + } + + // Sign the OCSP response + if (!OCSP_basic_sign(basic_resp.get(), ca_cert_.get(), ca_pkey_.get(), EVP_sha256(), ca_chain_.get(), 0)) { + throw std::runtime_error("Failed to sign the OCSP response"); + } + + // Serialize OCSP response + auto ocsp_response = ocspResponseToBytes(basic_resp); + const auto ocsp_bytes = shared_array(ocsp_response.begin(), ocsp_response.end()); + + log_debug_printf(status_setup, "Status: %d\n", status); + log_debug_printf(status_setup, "OCSP Status: %d\n", ocsp_status); + log_debug_printf(status_setup, "Status Date: %s\n", this_status_update.s.c_str()); + log_debug_printf(status_setup, "Status Vaidity: %s\n", status_valid_until_time.s.c_str()); + log_debug_printf(status_setup, "Revocation Date: %s\n", revocation_time_to_use.s.c_str()); + + return PVACertificateStatus(status, ocsp_status, ocsp_bytes, this_status_update, status_valid_until_time, revocation_time_to_use); +} + +/** + * @brief Converts a 64-bit unsigned integer (serial number) to an ASN.1 representation. + * + * This function converts the serial number + * to an ASN.1 representation. ASN.1 (Abstract Syntax Notation One) is a standard + * notation and set of rules for defining the structure of data. + * + * @param serial the serial number to convert to ASN1 format + * @return The ASN.1 representation of the serial number. + * + * @see uint64FromASN1() + */ +ossl_ptr CertStatusFactory::uint64ToASN1(const uint64_t& serial) { + ossl_ptr asn1_serial(ASN1_INTEGER_new(), false); + if (!asn1_serial) throw std::runtime_error(SB() << "Error converting serial number: " << serial); + + // Convert byte array to ASN1_INTEGER + ASN1_INTEGER_set_uint64(asn1_serial.get(), serial); + return asn1_serial; +} + +/** + * @brief Creates an OCSP certificate ID using the given digest algorithm. + * + * This function creates an OCSP (Online Certificate Status Protocol) certificate ID using + * the provided digest algorithm. The digest algorithm defaults to `EVP_sha1` if not specified. + * + * @param serial serial number of certificate + * @param digest The digest algorithm used to compute the OCSP ID. Defaults to EVP_sha1 + * + * @return The OCSP certificate ID. + */ +ossl_ptr CertStatusFactory::createOCSPCertId(const uint64_t& serial, const EVP_MD* digest) const { + if (!ca_cert_) throw std::runtime_error(SB() << "Can't create OCSP Cert ID: Null Certificate"); + + unsigned char issuer_name_hash[EVP_MAX_MD_SIZE]; + unsigned char issuer_key_hash[EVP_MAX_MD_SIZE]; + + // Compute issuer_name_hash + unsigned int issuer_name_hash_len = 0; + X509_NAME* issuer_name = X509_get_subject_name(ca_cert_.get()); + X509_NAME_digest(issuer_name, digest, issuer_name_hash, &issuer_name_hash_len); + + // Compute issuer_key_hash + unsigned int issuer_key_hash_len = 0; + ASN1_BIT_STRING* issuer_key = X509_get0_pubkey_bitstr(ca_cert_.get()); + pvxs::ossl_ptr mdctx(EVP_MD_CTX_new()); + EVP_DigestInit_ex(mdctx.get(), digest, nullptr); + EVP_DigestUpdate(mdctx.get(), issuer_key->data, issuer_key->length); + EVP_DigestFinal_ex(mdctx.get(), issuer_key_hash, &issuer_key_hash_len); + + // Convert uint64_t serial number to ASN1_INTEGER + ossl_ptr asn1_serial = uint64ToASN1(serial); + + // Create OCSP_CERTID + auto cert_id = ossl_ptr(OCSP_cert_id_new(digest, issuer_name, issuer_key, asn1_serial.get()), false); + if (!cert_id) throw std::runtime_error(SB() << "Failed to create cert_id: " << getError()); + + return cert_id; +} + +/** + * @brief Converts the given OCSP basic response to bytes. + * + * This function takes the OCSP response as input and converts it into a sequence of bytes. + * + * @param basic_resp The OCSP response to be converted. + * @return The sequence of bytes representing the OCSP response object. + */ +std::vector CertStatusFactory::ocspResponseToBytes(const ossl_ptr& basic_resp) { + ossl_ptr resp_der(nullptr, false); + ossl_ptr ocsp_resp(OCSP_response_create(OCSP_RESPONSE_STATUS_SUCCESSFUL, basic_resp.get())); + int resp_len = i2d_OCSP_RESPONSE(ocsp_resp.get(), resp_der.acquire()); + + std::vector resp_bytes(resp_der.get(), resp_der.get() + resp_len); + + return resp_bytes; +} + +/** + * @brief Get serial number from an owned cert + * @param cert owned cert + * @return serial number + */ +uint64_t CertStatusFactory::getSerialNumber(const ossl_ptr& cert) { return getSerialNumber(cert.get()); } + +/** + * @brief Get a serial number from a cert pointer + * @param cert cert pointer + * @return serial number + */ +uint64_t CertStatusFactory::getSerialNumber(X509* cert) { + if (!cert) { + throw std::runtime_error("Can't get serial number: Null certificate"); + } + + // Extract the serial number from the certificate + ASN1_INTEGER* serial_number_asn1 = X509_get_serialNumber(cert); + if (!serial_number_asn1) { + throw std::runtime_error("Failed to retrieve serial number from certificate"); + } + + // Convert ASN1_INTEGER to a 64-bit unsigned integer + return ASN1ToUint64(serial_number_asn1); +} + +} // namespace certs +} // namespace pvxs diff --git a/certs/certstatusfactory.h b/certs/certstatusfactory.h new file mode 100644 index 000000000..6b8ca12c4 --- /dev/null +++ b/certs/certstatusfactory.h @@ -0,0 +1,160 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The certificate status factory class + * + * certstatusfactory.h + * + */ +#ifndef PVXS_CERTSTATUSFACTORY_H_ +#define PVXS_CERTSTATUSFACTORY_H_ + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +#include +#include +#include + +#include "certstatus.h" +#include "ownedptr.h" + +namespace pvxs { +namespace certs { + +/** + * @brief Class used to create OCSP certificate status responses + * + * You can create a cert_status_creator and reuse it to make response statuses for + * certificates providing their serial number and the desired status by calling + * `createPVACertificateStatus()`. + * + * When using the getters (e.g. status()) be aware that they are references into + * the class and so each time you call createPVACertificateStatus() these reference values + * change. + * + * @code + * static auto cert_status_creator(CertStatusFactory(config, ca_cert, ca_pkey, ca_chain)); + * auto cert_status = cert_status_creator.createPVACertificateStatus(serial, new_state); + * @endcode + */ +class CertStatusFactory { + public: + /** + * @brief Used to make OCSP responses for given statuses + * You need the private key of the CA in order to do this. + * Subsequently call createPVACertificateStatus() to make responses for certificates + * + * @param ca_cert the CA certificate to use to sign the OCSP response + * @param ca_pkey the CA's private key to use to sign the response + * @param ca_chain the CA's certificate change used to sign any response + * @param cert_status_validity_mins_ the number of minutes the status is valid for + * + * @see createPVACertificateStatus() + */ + CertStatusFactory(const ossl_ptr& ca_cert, const pvxs::ossl_ptr& ca_pkey, const pvxs::ossl_shared_ptr& ca_chain, + uint32_t cert_status_validity_mins = 30, uint32_t cert_status_validity_secs = 0) + : ca_cert_(ca_cert), + ca_pkey_(ca_pkey), + ca_chain_(ca_chain), + cert_status_validity_mins_(cert_status_validity_mins), + cert_status_validity_secs_(cert_status_validity_secs) {}; + + /** + * @brief Create OCSP status for certificate identified by serial number + * The configured ca_cert and ca_chain is encoded into the response so that consumers of the response can determine the issuer + * and the chain of trust. The issuer will have to have previously trusted the root certificate as this will + * be verified. The response will be signed with the configured private key so that authenticity of the response can be verified. + * + * The result contains the signed OCSP response as well as unencrypted OCSP status, status date , status validity date and + * revocation date if applicable. + * The PVA status is also included for completeness + * + * @param serial the serial number of the certificate to create an OCSP response for + * @param status the PVA certificate status to create an OCSP response with + * @param status_date the status date to set in the OCSP response + * @param predicated_revocation_time the revocation date to set in the OCSP response if applicable + * + * @return the Certificate Status containing the signed OCSP response and other OCSP response data. + */ + PVACertificateStatus createPVACertificateStatus(const ossl_ptr& cert, certstatus_t status, StatusDate status_date = std::time(nullptr), + StatusDate predicated_revocation_time = std::time(nullptr)) const; + PVACertificateStatus createPVACertificateStatus(uint64_t serial, certstatus_t status, StatusDate status_date = std::time(nullptr), + StatusDate predicated_revocation_time = std::time(nullptr)) const; + + /** + * @brief Convert ASN1_INTEGER to a 64-bit unsigned integer + * @param asn1_number + * @return + */ + static inline uint64_t ASN1ToUint64(ASN1_INTEGER* asn1_number) { + uint64_t uint64_number = 0; + for (int i = 0; i < asn1_number->length; ++i) { + uint64_number = (uint64_number << 8) | asn1_number->data[i]; + } + return uint64_number; + } + + static uint64_t getSerialNumber(const ossl_ptr& cert); + + private: + const ossl_ptr& ca_cert_; // CA Certificate to encode in the OCSP responses + const pvxs::ossl_ptr& ca_pkey_; // CA Certificate's private key to sign the OCSP responses + const pvxs::ossl_shared_ptr& ca_chain_; // CA Certificate chain to encode in the OCSP responses + const uint32_t cert_status_validity_mins_; // The status validity period in minutes to encode in the OCSP responses + const uint32_t cert_status_validity_secs_; // The status validity period additional seconds to encode in the OCSP responses + + /** + * @brief Internal function to create an OCSP CERTID. Uses CertStatusFactory configuration + * @param digest the method to use to create the CERTID + * @return an OCSP CERTID + */ + pvxs::ossl_ptr createOCSPCertId(const uint64_t& serial, const EVP_MD* digest = EVP_sha1()) const; + /** + * @brief Internal function to convert an OCSP_BASICRESP into a byte array + * @param basic_resp the OCSP_BASICRESP to convert + * @return a byte array + */ + static std::vector ocspResponseToBytes(const pvxs::ossl_ptr& basic_resp); + + static uint64_t getSerialNumber(X509* cert); + + /** + * @brief Internal function to convert a PVA serial number into an ASN1_INTEGER + * @param serial the serial number to convert + * @return ASN1_INTEGER + */ + static pvxs::ossl_ptr uint64ToASN1(const uint64_t& serial); + + static inline std::string getError() { + unsigned long err; + std::string error_string; + std::string sep; + while ((err = ERR_get_error())) // get all error codes from the error queue + { + char buffer[256]; + ERR_error_string_n(err, buffer, sizeof(buffer)); + error_string += sep + buffer; + sep = ", "; + } + return error_string; + } +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_CERTSTATUSFACTORY_H_ diff --git a/certs/configcms.cpp b/certs/configcms.cpp new file mode 100644 index 000000000..590003335 --- /dev/null +++ b/certs/configcms.cpp @@ -0,0 +1,196 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "configcms.h" + +#include + +DEFINE_LOGGER(_logname, "pvxs.certs.cfg"); + +namespace pvxs { +namespace certs { + +void ConfigCms::fromCmsEnv(const std::map &defs) { + PickOne pickone{defs, true}; + PickOne pick_another_one{defs, true}; + + // EPICS_KEYCHAIN ( default the private key to use the same file and password ) + if (pickone({"EPICS_PVACMS_TLS_KEYCHAIN", "EPICS_PVAS_TLS_KEYCHAIN"})) { + ensureDirectoryExists(tls_cert_filename = pickone.val); + + // EPICS_CA_KEYCHAIN_PWD_FILE + std::string password_filename; + if (pickone.name == "EPICS_PVACMS_TLS_KEYCHAIN") { + pick_another_one({"EPICS_PVACMS_TLS_KEYCHAIN_PWD_FILE"}); + password_filename = pick_another_one.val; + } else if (pickone.name == "EPICS_PVAS_TLS_KEYCHAIN") { + pick_another_one({"EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE"}); + password_filename = pick_another_one.val; + } + ensureDirectoryExists(password_filename); + try { + tls_cert_password = getFileContents(password_filename); + } catch (std::exception &e) { + log_err_printf(_logname, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + + // EPICS_PKEY + if (pickone({"EPICS_PVACMS_TLS_PKEY", "EPICS_PVAS_TLS_PKEY"})) { + ensureDirectoryExists(tls_private_key_filename = pickone.val); + + // EPICS_CA_PKEY_PWD_FILE + std::string password_filename; + if (pickone.name == "EPICS_PVACMS_TLS_PKEY") { + pick_another_one({"EPICS_PVACMS_TLS_PKEY_PWD_FILE"}); + password_filename = pick_another_one.val; + } else if (pickone.name == "EPICS_PVAS_TLS_PKEY") { + pick_another_one({"EPICS_PVAS_TLS_PKEY_PWD_FILE"}); + password_filename = pick_another_one.val; + } + ensureDirectoryExists(password_filename); + try { + tls_private_key_password = getFileContents(password_filename); + } catch (std::exception &e) { + log_err_printf(_logname, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + + // EPICS_PVAS_TLS_STOP_IF_NO_CERT + if (pickone({"EPICS_PVACMS_TLS_STOP_IF_NO_CERT", "EPICS_PVAS_TLS_STOP_IF_NO_CERT"})) { + tls_stop_if_no_cert = parseTo(pickone.val); + } + + // EPICS_PVACMS_ACF + if (pickone({"EPICS_PVACMS_ACF"})) { + ensureDirectoryExists(ca_acf_filename = pickone.val); + } + + // EPICS_PVACMS_DB + if (pickone({"EPICS_PVACMS_DB"})) { + ensureDirectoryExists(ca_db_filename = pickone.val); + } + + // EPICS_CA_KEYCHAIN + if (pickone({"EPICS_CA_KEYCHAIN"})) { + ensureDirectoryExists(ca_cert_filename = pickone.val); + + // EPICS_CA_KEYCHAIN_PWD_FILE + if (pickone.name == "EPICS_CA_KEYCHAIN") { + pick_another_one({"EPICS_CA_KEYCHAIN_PWD_FILE"}); + std::string password_filename = pick_another_one.val; + ensureDirectoryExists(password_filename); + try { + ca_cert_password = getFileContents(password_filename); + } catch (std::exception &e) { + log_err_printf(_logname, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + } + + // EPICS_CA_PKEY + if (pickone({"EPICS_CA_PKEY"})) { + ensureDirectoryExists(ca_private_key_filename = pickone.val); + + // EPICS_CA_PKEY_PWD_FILE + if (pickone.name == "EPICS_CA_PKEY") { + pick_another_one({"EPICS_CA_PKEY_PWD_FILE"}); + std::string password_filename = pick_another_one.val; + ensureDirectoryExists(password_filename); + try { + ca_private_key_password = getFileContents(password_filename); + } catch (std::exception &e) { + log_err_printf(_logname, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + } + + // EPICS_ADMIN_TLS_KEYCHAIN + if (pickone({"EPICS_ADMIN_TLS_KEYCHAIN"})) { + ensureDirectoryExists(admin_cert_filename = pickone.val); + + // EPICS_CA_KEYCHAIN_PWD_FILE + if (pickone.name == "EPICS_ADMIN_TLS_KEYCHAIN") { + pick_another_one({"EPICS_ADMIN_TLS_KEYCHAIN_PWD_FILE"}); + std::string password_filename = pick_another_one.val; + ensureDirectoryExists(password_filename); + try { + ca_cert_password = getFileContents(password_filename); + } catch (std::exception &e) { + log_err_printf(_logname, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + } + + // EPICS_CA_PKEY + if (pickone({"EPICS_CA_PKEY"})) { + ensureDirectoryExists(ca_private_key_filename = pickone.val); + + // EPICS_CA_PKEY_PWD_FILE + if (pickone.name == "EPICS_CA_PKEY") { + pick_another_one({"EPICS_CA_PKEY_PWD_FILE"}); + std::string password_filename = pick_another_one.val; + ensureDirectoryExists(password_filename); + try { + ca_private_key_password = getFileContents(password_filename); + } catch (std::exception &e) { + log_err_printf(_logname, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + } + + // EPICS_CA_NAME + if (pickone({"EPICS_CA_NAME"})) { + ca_name = pickone.val; + } + + // EPICS_CA_ORGANIZATION + if (pickone({"EPICS_CA_ORGANIZATION"})) { + ca_organization = pickone.val; + } + + // EPICS_CA_ORGANIZATIONAL_UNIT + if (pickone({"EPICS_CA_ORGANIZATIONAL_UNIT"})) { + ca_organizational_unit = pickone.val; + } + + // EPICS_CA_COUNTRY + if (pickone({"EPICS_CA_COUNTRY"})) { + ca_country = pickone.val; + } + + // EPICS_PVACMS_CERT STATUS VALIDITY MINS + if (pickone({"EPICS_PVACMS_CERT_STATUS_VALIDITY_MINS"})) { + try { + cert_status_validity_mins = parseTo(pickone.val); + } catch (std::exception &e) { + log_err_printf(_logname, "%s invalid integer : %s", pickone.name.c_str(), e.what()); + } + } + + // EPICS_PVACMS_REQUIRE_CLIENT_APPROVAL + if (pickone({"EPICS_PVACMS_REQUIRE_CLIENT_APPROVAL"})) { + cert_client_require_approval = parseTo(pickone.val); + } + + // EPICS_PVACMS_REQUIRE_SERVER_APPROVAL + if (pickone({"EPICS_PVACMS_REQUIRE_SERVER_APPROVAL"})) { + cert_server_require_approval = parseTo(pickone.val); + } + + // EPICS_PVACMS_REQUIRE_SERVER_APPROVAL + if (pickone({"EPICS_PVACMS_REQUIRE_GATEWAY_APPROVAL", "EPICS_PVACMS_REQUIRE_SERVER_APPROVAL", "EPICS_PVACMS_REQUIRE_CLIENT_APPROVAL"})) { + cert_gateway_require_approval = parseTo(pickone.val); + } + + // EPICS_PVACMS_CERTS_REQUIRE_SUBSCRIPTION + if (pickone({"EPICS_PVACMS_CERTS_REQUIRE_SUBSCRIPTION"})) { + cert_status_subscription = parseTo(pickone.val); + } +} + +} // namespace certs +} // namespace pvxs diff --git a/certs/configcms.h b/certs/configcms.h new file mode 100644 index 000000000..aab971782 --- /dev/null +++ b/certs/configcms.h @@ -0,0 +1,274 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CONFIGCMS_H_ +#define PVXS_CONFIGCMS_H_ + +#include + +#include +#include + +#include "ownedptr.h" + +namespace pvxs { +namespace certs { + +class ConfigCms : public pvxs::server::Config { + public: + ConfigCms& applyEnv() { + pvxs::server::Config::applyEnv(); + return *this; + } + + /** + * @brief Create a CMS configuration from environment variables + * + * @return ConfigCms + */ + static inline ConfigCms fromEnv() { + // Get default config + auto config = ConfigCms{}.applyEnv(); + + // Indicate that this is a CMS configuration + config.config_target = pvxs::impl::ConfigCommon::CMS; + + // Disable status checking as this is the CMS itself + config.tls_disable_status_check = true; + + // Override with any specific CMS configuration from environment variables + config.fromCmsEnv(std::map()); + return config; + } + + /** + * @brief Minutes that the ocsp status response will + * be valid before a client must re-request an update + */ + uint32_t cert_status_validity_mins = 30; + + /** + * @brief When basic credentials are used then set to true to + * request administrator approval to issue client certificates. + * + * All other auth methods will never require administrator approval. + */ + bool cert_client_require_approval = true; + + /** + * @brief When basic credentials are used then set to true + * to request administrator approval to issue server certificates. + * + * All other auth methods will never require administrator approval. + */ + bool cert_server_require_approval = true; + + /** + * @brief When basic credentials are used then set to true + * to request administrator approval to issue gateway certificates. + * + * All other auth methods will never require administrator approval. + */ + bool cert_gateway_require_approval = true; + + /** + * @brief This flag is used to indicate that a certificate user must subscribe + * to the certificate status PV to verify certificate's revoked status. + * + * With this flag set two extensions are added to created certificates. + * A flag indicating that subscription is required and a string + * containing the PV name to subscribe to. + * + * If the flag is false certificate validity will work as normal + * but clients will not know that they have been revoked. + * + * Default is true + */ + bool cert_status_subscription = true; + + /** + * @brief This is the string that determines the fully + * qualified path to a file that will be used as the sqlite PVACMS + * certificate database for a PVACMS process. + * + * The default is the current directory in a file called certs.db + */ + std::string ca_db_filename = "certs.db"; + + /** + * @brief This is the string that determines + * the fully qualified path to the keychain file that contains + * the CA certificate, and public and private keys. + * + * This is used to sign certificates being created in the PVACMS or + * sign certificate status responses being delivered by OCSP-PVA. + * If this is not specified it defaults to the TLS_KEYCHAIN file. + * + * Note: This certificate needs to be trusted by all EPICS agents. + */ + std::string ca_cert_filename; + + /** + * @brief This is the string that determines + * the fully qualified path to a file that contains the password that + * unlocks the `ca_cert_filename`. + * + * This is optional. If not specified, the `ca_cert_filename` + * contents will not be encrypted. + */ + std::string ca_cert_password; + + /** + * @brief This is the string that determines + * the fully qualified path to the private key file that contains + * the private keys. + * + * This is optional. If not specified, the `ca_cert_filename` is used. + */ + std::string ca_private_key_filename; + + /** + * @brief This is the string that determines + * the fully qualified path to a file that contains the password that + * unlocks the `ca_pkey_filename`. + */ + std::string ca_private_key_password; + + /** + * @brief This is the string that determines + * the fully qualified path to the keychain file that contains + * the admin user's certificate, and public and private keys. + */ + std::string admin_cert_filename; + + /** + * @brief This is the string that determines + * the fully qualified path to a file that contains the password that + * unlocks the admin user's keychain file. + */ + std::string admin_cert_password; + + /** + * @brief This is the string that determines + * the fully qualified path to the admin user's private key file that contains + * the private keys. + */ + std::string admin_private_key_filename; + + /** + * @brief This is the string that determines + * the fully qualified path to a file that contains the password that + * unlocks the admin user's private key file. + */ + std::string admin_private_key_password; + + /** + * @brief This is the string that determines the + * fully qualified path to a file that will be used as the + * ACF file that configures the permissions that are accorded + * to validated peers of the PVACMS. + * + * This will specify administrators that have the right to revoke + * certificates, and the default read permissions for certificate statuses. + * There is no default so it must be specified on the command line or + * as an environment variable. + * + * e.g. + * @code + * USG(ADMINS) { + * "admin", + * "admin@yourdomain.com" + * } + * + * ASG(SPECIAL) { + * RULE(0,READ) + * RULE(1,WRITE) { + * UAG(ADMINS) + * METHOD("x509") + * AUTHORITY("CN of your Certificate Authority") + * } + * + * @endcode + * + */ + std::string ca_acf_filename{"pvacms.acf"}; + + /** + * @brief If a CA root certificate has not been established + * prior to the first time that the PVACMS starts up, then one + * will be created automatically. + * + * To provide the name (CN) to be used in the subject of the + * CA certificate we can use this environment variable. + */ + std::string ca_name = "EPICS Root CA"; + + /** + * @brief If a CA root certificate has not been established + * prior to the first time that the PVACMS starts up, then one will be + * created automatically. + * + * To provide the organization (O) to be used in the subject of + * the CA certificate we can use this environment variable. + */ + std::string ca_organization = "ca.epics.org"; + + /** + * @brief If a CA root certificate has not been + * established prior to the first time that the PVACMS starts up, + * then one will be created automatically. + * + * To provide the organizational unit (OU) to be used in the + * subject of the CA certificate we can use this environment variable. + */ + std::string ca_organizational_unit = "EPICS Certificate Authority"; + + /** + * @brief The CA Country + */ + std::string ca_country; + + /** + * @brief If a PVACMS certificate has not been established + * prior to the first time that the PVACMS starts up, then one + * will be created automatically. + * + * To provide the name (CN) to be used in the subject of the + * PVACMS certificate we can use this environment variable. + */ + std::string pvacms_name = "PVACMS Service"; + + /** + * @brief If a PVACMS certificate has not been established + * prior to the first time that the PVACMS starts up, then one will be + * created automatically. + * + * To provide the organization (O) to be used in the subject of + * the PVACMS certificate we can use this environment variable. + */ + std::string pvacms_organization = "ca.epics.org"; + + /** + * @brief If a PVACMS certificate has not been + * established prior to the first time that the PVACMS starts up, + * then one will be created automatically. + * + * To provide the organizational unit (OU) to be used in the + * subject of the PVACMS certificate we can use this environment variable. + */ + std::string pvacms_organizational_unit = "EPICS PVA Certificate Management Service"; + + /** + * @brief The PVACMS Country + */ + std::string pvacms_country; + + void fromCmsEnv(const std::map& defs); +}; + +} // namespace certs +} // namespace pvxs +#endif // PVXS_CONFIGCMS_H_ diff --git a/certs/configocsp.cpp b/certs/configocsp.cpp new file mode 100644 index 000000000..45e05c7cd --- /dev/null +++ b/certs/configocsp.cpp @@ -0,0 +1,26 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "configocsp.h" + +std::unique_ptr getConfigFactory() { + struct ConfigCmsFactory : public ConfigFactoryInterface { + std::unique_ptr create() override { + // EPICS_OCSP_PORT + if (pickone({"EPICS_OCSP_PORT"})) { + try { + self.ocsp_port = parseTo(pickone.val); + } catch (std::exception &e) { + log_err_printf(serversetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); + } + } + + return std::make_unique(); + } + }; + + return std::make_unique(); +} diff --git a/certs/configocsp.h b/certs/configocsp.h new file mode 100644 index 000000000..d0487a843 --- /dev/null +++ b/certs/configocsp.h @@ -0,0 +1,50 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_CONFIGCMS_H_ +#define PVXS_CONFIGCMS_H_ + +#include + +#include "ownedptr.h" + +class ConfigCms : public Config { + public: + /** + * @brief The port for the OCSP server to listen on. + */ + unsigned short ocsp_port = 8080; + + /** + * @brief This is the string that determines + * the fully qualified path to the keychain file that contains + * the CA certificate, and public and private keys. + * + * This is used to sign certificates being created in the PVACMS or + * sign certificate status responses being delivered by OCSP-PVA. + * If this is not specified it defaults to the TLS_KEYCHAIN file. + * + * Note: This certificate needs to be trusted by all EPICS agents. + */ + std::string ca_cert_filename; + + /** + * @brief This is the string that determines + * the fully qualified path to a file that contains the password that + * unlocks the `ca_cert_filename`. + * + * This is optional. If not specified, the `ca_cert_filename` + * contents will not be encrypted. + */ + std::string ca_cert_password; +}; + +class ConfigCmsFactory : public ConfigFactoryInterface { + public: + std::unique_ptr create() override { return std::make_unique(); } +}; + +#endif // PVXS_CONFIGCMS_H_ diff --git a/certs/ocsppva.cpp b/certs/ocsppva.cpp new file mode 100644 index 000000000..99c41fdd5 --- /dev/null +++ b/certs/ocsppva.cpp @@ -0,0 +1,262 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The PVAccess Certificate Management Service. + * + * pvacms + * + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "ownedptr.h" +#include "p12filefactory.h" +#include "pvacms.h" + +using namespace pvxs; + +#define DEFAULT_PORT "8080" + +namespace { + +typedef std::shared_ptr ConfigPtr; // aliasing for simplicity. + +// A function that returns shared_ptr instance. +ConfigPtr clientConfig(client::Config *inital_ptr = nullptr) { + static ConfigPtr config_ptr(inital_ptr); + return config_ptr; +} + +/** + * @brief Prints the usage message for the program. + * + * This function prints the usage message for the program, including + * the available command line options and their descriptions. + * + * @param argv0 The name of the program (usually argv[0]). + */ +void usage(const char *argv0) { + std::cerr << "Usage: " << argv0 + << " \n" + "\n" + " -h Show this message.\n" + " -V Print version and exit.\n" + " -v Make more noise.\n" + " -p Specify port to listen on\n"; +} + +/** + * @brief Reads command line options and sets corresponding variables. + * + * This function reads the command line options provided by the user and + * sets the corresponding variables. The options include verbose mode, + * and port. + * + * @param argc The number of command line arguments. + * @param argv The array of command line arguments. + * @param verbose Reference to a boolean variable to enable verbose mode. + * @param port The string variable to store the P12 file location. + * @return 0 if successful, 1 if successful but need to exit immediately on + * return, >1 if there is any error. + */ +int readOptions(int argc, char *argv[], bool &verbose, std::string &port) { + int opt; + while ((opt = getopt(argc, argv, "hVvp:")) != -1) { + switch (opt) { + case 'h': + usage(argv[0]); + return 1; + case 'V': + std::cout << version_information; + return 1; + case 'v': + verbose = true; + break; + case 'p': + port = optarg; + break; + default: + usage(argv[0]); + std::cerr << "\nUnknown argument: " << char(opt) << std::endl; + return 2; + } + } + return 0; +} + +bool getCertificateStatus(client::Context &pva_client, const uint64_t serial, CertStatus &status) { + std::string pvacms_uri = GET_CERT_STATUS; + std::size_t pos = pvacms_uri.find('*'); + + if (pos != std::string::npos) { + std::string str_serial = std::to_string(serial); + pvacms_uri.replace(pos, 1, str_serial); + } + + // Build and start network operation + auto operation = pva_client.get(pvacms_uri).exec(); + + // wait for it to complete, for up to 3 seconds. + Value result = operation->wait(3.0); + + status = result["value"].as(); + return true; +} + +int ocspService(client::Context &pva_client, std::string &port, bool verbose) { + OpenSSL_add_all_algorithms(); + SSL_load_error_strings(); + ERR_load_BIO_strings(); + OpenSSL_add_all_ciphers(); + + auto config = clientConfig().get(); + auto cert_filename = config->tls_cert_filename; + auto cert_password = config->tls_cert_password; + + auto key_chain_data = security::KeychainFactory::getKeychainDataFromKeychainFile(cert_filename, cert_password); + const ossl_ptr ca_pkey(std::move(key_chain_data.pkey)); + const ossl_ptr ca_cert(std::move(key_chain_data.cert)); + const ossl_ptr ca_pub_key(X509_get_pubkey(ca_cert.get())); + const ossl_shared_ptr ca_chain(key_chain_data.ca); + + ossl_ptr ctx(SSL_CTX_new(TLS_server_method())); + + // Listen on port + ossl_ptr_all bio_acc(BIO_new_accept(port.c_str())); + if (BIO_do_accept(bio_acc.get()) <= 0) { + throw std::runtime_error(SB() << "Error setting up accept BIO for OCSP"); + } + + while (true) { + if (BIO_do_accept(bio_acc.get()) <= 0) { + std::cerr << "Error accepting connection" << std::endl; + continue; + } + + BIO *bio = BIO_pop(bio_acc.get()); + ossl_ptr ssl(SSL_new(ctx.get())); + SSL_set_bio(ssl.get(), bio, bio); + + if (SSL_accept(ssl.get()) <= 0) { + std::cerr << "Error accepting SSL connection" << std::endl; + ssl.release(); + continue; + } + + ossl_ptr ocsp_req(d2i_OCSP_REQUEST_bio(SSL_get_rbio(ssl.get()), nullptr)); + if (ocsp_req == nullptr) { + std::cerr << "Error reading OCSP request" << std::endl; + ssl.release(); + continue; + } + + ossl_ptr ocsp_resp(OCSP_response_create(OCSP_RESPONSE_STATUS_SUCCESSFUL, nullptr)); + ossl_ptr basic_resp(OCSP_BASICRESP_new()); + + for (auto i = 0; i < OCSP_request_onereq_count(ocsp_req.get()); i++) { + OCSP_ONEREQ *one_req = OCSP_request_onereq_get0(ocsp_req.get(), i); + OCSP_CERTID *cert_id = OCSP_onereq_get0_id(one_req); + ASN1_INTEGER *asn1_serial = nullptr; + OCSP_id_get0_info(nullptr, nullptr, nullptr, &asn1_serial, cert_id); + BIGNUM *big_number = ASN1_INTEGER_to_BN(asn1_serial, nullptr); + ossl_ptr hex_serial(BN_bn2hex(big_number)); + uint64_t serial = std::strtoull(hex_serial.get(), nullptr, 16); + BN_free(big_number); + + CertStatus status; + if (getCertificateStatus(pva_client, serial, status)) { + int ocsp_status; + switch (status) { + case VALID: + ocsp_status = V_OCSP_CERTSTATUS_GOOD; + break; + case REVOKED: + ocsp_status = V_OCSP_CERTSTATUS_REVOKED; + break; + default: + ocsp_status = V_OCSP_CERTSTATUS_UNKNOWN; + break; + } + + ASN1_TIME *revocation_time = ASN1_TIME_new(); + X509_gmtime_adj(revocation_time, 0); + + OCSP_basic_add1_status(basic_resp.get(), cert_id, ocsp_status, OCSP_REVOKED_STATUS_NOSTATUS, revocation_time, nullptr, nullptr); + ASN1_TIME_free(revocation_time); + } + + OCSP_copy_nonce(basic_resp.get(), ocsp_req.get()); + OCSP_basic_sign(basic_resp.get(), ca_cert.get(), ca_pkey.get(), EVP_sha256(), ca_chain.get(), 0); + ocsp_resp.reset(OCSP_response_create(OCSP_RESPONSE_STATUS_SUCCESSFUL, basic_resp.get())); + + BIO *bio_resp = BIO_new(BIO_s_mem()); + i2d_OCSP_RESPONSE_bio(bio_resp, ocsp_resp.get()); + int len = BIO_pending(bio_resp); + char *resp_data = new char[len]; + BIO_read(bio_resp, resp_data, len); + + SSL_write(ssl.get(), resp_data, len); + + delete[] resp_data; + SSL_shutdown(ssl.get()); + } + } + return 0; +} + +} // namespace + +int main(int argc, char *argv[]) { + try { + logger_config_env(); // Logger config from environment + bool verbose = false; + std::string port = DEFAULT_PORT; + + // Read commandline options + int exit_status; + if ((exit_status = readOptions(argc, argv, verbose, port))) { + return exit_status - 1; + } + + auto pva_client(client::Context::fromEnv()); + auto config = pva_client.config(); + config.config_target = client::Config::OCSPPVA; + clientConfig(&config); + + if (verbose) std::cout << "Effective config\n" << config; + std::cout << "\nPVA OCSP Server Ready\n"; + + ocspService(pva_client, port, verbose); + + std::cout << "Done\n"; + + return 0; + } catch (std::exception &e) { + std::cerr << "OCSP-PVA Error: " << e.what() << "\n"; + return 1; + } +} diff --git a/certs/p12filefactory.cpp b/certs/p12filefactory.cpp new file mode 100644 index 000000000..4a4ffc93a --- /dev/null +++ b/certs/p12filefactory.cpp @@ -0,0 +1,278 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include +#include +#include +#include + +#include + +#ifdef __unix__ +#include +#endif +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include "certfactory.h" +#include "openssl.h" +#include "osiFileName.h" +#include "ownedptr.h" +#include "p12filefactory.h" +#include "security.h" +#include "utilpvt.h" + +namespace pvxs { +namespace certs { + +DEFINE_LOGGER(certs, "pvxs.certs.fms"); + +/** + * @brief Get a key pair from a P12 file + * + * @param filename the path to the P12 file + * @param password the optional password for the file. If blank then the password is not used. + * @return a shared pointer to the KeyPair object + * @throw std::runtime_error if the file cannot be opened or parsed + */ +std::shared_ptr P12FileFactory::getKeyFromFile() { + file_ptr fp(fopen(filename_.c_str(), "rb"), false); + if (!fp) { + throw std::runtime_error(SB() << "Error opening private key file: \"" << filename_ << "\": " << strerror(errno)); + } + + ossl_ptr p12(d2i_PKCS12_fp(fp.get(), NULL), false); + if (!p12) { + throw std::runtime_error(SB() << "Error opening private key file as a PKCS#12 object: " << filename_); + } + + ossl_ptr pkey; + if (!PKCS12_parse(p12.get(), password_.c_str(), pkey.acquire(), nullptr, nullptr)) { + throw ossl::SSLError(SB() << "Error parsing private key file: " << filename_); + } + + return std::make_shared(std::move(pkey)); +} + +/** + * @brief Get the certificate data from a P12 file + * + * The P12 file is parsed to extract the certificate and chain. + * If it contains a private key too then it is read and returned in the CertData object. + * + * @return a CertData object + * @throw std::runtime_error if the file cannot be opened or parsed + */ +CertData P12FileFactory::getCertDataFromFile() { + ossl_ptr cert; + STACK_OF(X509) *chain_ptr = nullptr; + std::shared_ptr key_pair; + ossl_ptr pkey; + + // Get cert from configured file + auto file(fopen(filename_.c_str(), "rb")); + if (!file) { + throw std::runtime_error(SB() << "Error opening certificate file for reading binary contents: \"" << filename_ << "\""); + } + file_ptr fp(file); + + ossl_ptr p12(d2i_PKCS12_fp(fp.get(), NULL), false); + if (!p12) { + throw std::runtime_error(SB() << "Error opening certificate file as a PKCS#12 object: " << filename_); + } + + // Try to get private key and certificate but if we can't then try only certs + if (!PKCS12_parse(p12.get(), password_.c_str(), pkey.acquire(), cert.acquire(), &chain_ptr)) { + if (!PKCS12_parse(p12.get(), password_.c_str(), nullptr, cert.acquire(), &chain_ptr)) { + throw std::runtime_error(SB() << "Error parsing certificate file: " << filename_); + } + } + + // Try to get key from file if we didn't already get it and it is configured + if (!pkey && key_file_) { + key_pair = key_file_->getKeyFromFile(); + pkey = std::move(key_pair->pkey); + } + + ossl_shared_ptr chain; + if (chain_ptr) + chain = ossl_shared_ptr(chain_ptr); + else + chain = ossl_shared_ptr(sk_X509_new_null()); + + if (pkey) { + return CertData(cert, chain, std::make_shared(std::move(pkey))); + } else { + return CertData(cert, chain); + } +} + +/** + * @brief Convert a PEM string to a P12 object + * + * @param password the optional password for the file. If blank then the password is not used. + * @param keys_ptr the private key to include in the P12 file. Note that this is required if there are any certificates in the PEM string. + * @param pem_string the PEM string to convert. May contain certificates, and certificate chains. We will + * read the first certificate and use is as the subject of the P12 file. The remaining certificates + * will be added to the chain of the P12 file. As a convention the order of the certificates in the + * PEM string is the entity certificate first, intermediate certificates next and then finally the CA certificate. + * @return an owned pointer to the PKCS12 object + * @throw std::runtime_error if the PEM string cannot be parsed + */ +ossl_ptr P12FileFactory::pemStringToP12(std::string password, EVP_PKEY *keys_ptr, std::string pem_string, bool certs_only) { + // Read PEM data into a new BIO + ossl_ptr bio(BIO_new_mem_buf(pem_string.c_str(), -1), false); + if (!bio) { + throw std::runtime_error("Unable to allocate BIO"); + } + + // Get first Cert as Certificate + ossl_ptr cert(PEM_read_bio_X509_AUX(bio.get(), NULL, NULL, (void *)password.c_str()), false); + if (!cert) { + throw std::runtime_error("Unable to read certificate"); + } + + // Get the chain + ossl_ptr certs(sk_X509_new_null()); + if (!certs) { + throw std::runtime_error("Unable to allocate certificate stack"); + } + + // Get whole of certificate chain and push to certs + ossl_ptr ca; + while (X509 *ca_ptr = PEM_read_bio_X509(bio.get(), NULL, NULL, (void *)password.c_str())) { + ca = ossl_ptr(ca_ptr); + sk_X509_push(certs.get(), ca.release()); + } + + return toP12(password, keys_ptr, cert.get(), certs.get(), certs_only); +} + +/** + * @brief Convert an entity certificate and the certificate chain to a P12 object + * + * @param password the optional password for the p12 object. If blank then the password is not used. + * @param keys_ptr the private key to include in the p12 object. Note that this is required if there are any certificates in the PEM string. + * @param cert_ptr the entity (subject) certificate of the p12 object + * @param cert_chain_ptr the chain of certificates to include in the p12 object + * @param certs_only if true then only the certificates are included in the p12 object. + * Note that this is likely to produce p12 objects that are incompatible with some applications. + * It is not recommended unless there is a specific reason for this. + * @return a shared pointer to the PKCS12 object + * @throw std::runtime_error if the certificate and key cannot be found or an error occurs + */ +ossl_ptr P12FileFactory::toP12(std::string password, EVP_PKEY *keys_ptr, X509 *cert_ptr, STACK_OF(X509) * cert_chain_ptr, bool certs_only) { + // Get the subject name of the certificate + if (!cert_ptr && !keys_ptr) throw std::runtime_error("No certificate or key provided"); + + ossl_ptr p12; + if (!cert_ptr) { + if (certs_only) { + throw std::runtime_error("Unable to create p12 no certificates and no keys"); + } + p12.reset(PKCS12_create_ex2(password.c_str(), nullptr, keys_ptr, nullptr, nullptr, 0, 0, 0, 0, 0, nullptr, nullptr, nullptr, nullptr)); + } else { + auto subject_name(X509_get_subject_name(cert_ptr)); + auto subject_string(X509_NAME_oneline(subject_name, nullptr, 0)); + ossl_ptr subject(subject_string, false); + if (!subject) { + throw std::runtime_error("Unable to get the subject of the certificate"); + } + + // Create the p12 structure + if (sk_X509_num(cert_chain_ptr) < 1) { + // Use null cert and construct chain from cert + chainFromRootCertPtr(cert_chain_ptr, cert_ptr); + ERR_clear_error(); + // TODO find a way to write cert-only p12 files + p12.reset(PKCS12_create_ex2(password.c_str(), subject.get(), (certs_only) ? nullptr : keys_ptr, nullptr, cert_chain_ptr, 0, 0, 0, 0, 0, nullptr, + nullptr, &jdkTrust, nullptr)); + } else { + p12.reset(PKCS12_create_ex2(password.c_str(), subject.get(), (certs_only) ? nullptr : keys_ptr, cert_ptr, cert_chain_ptr, 0, 0, 0, 0, 0, nullptr, + nullptr, &jdkTrust, nullptr)); + } + } + + if (!p12) { + throw std::runtime_error(SB() << "Unable to create PKCS12: " << CertFactory::getError()); + } + + return p12; +} + +/** + * @brief Write the P12 object to a file + * + * If a pem string has been specified then it is converted to a p12 object. + * If a cert has been specified then it is converted to a p12 object. + * If the only thing specified is a key pair then it is converted to a p12 object. + * + * If the file already exists then it will be backed up. + * + * @throw std::runtime_error if the file cannot be written + */ +void P12FileFactory::writePKCS12File() { + // If a pem string has been specified then convert to p12 + ossl_ptr p12; + if (!pem_string_.empty()) { + p12 = pemStringToP12(password_, key_pair_->pkey.get(), pem_string_); + } else if (cert_ptr_) { + // If a cert has been specified then convert to p12 + p12 = toP12(password_, key_pair_->pkey.get(), cert_ptr_, certs_ptr_, certs_only_); + } else if (key_pair_->pkey.get()) { + // If private key only + p12 = toP12(password_, key_pair_->pkey.get(), nullptr, nullptr); + } + + p12_ptr_ = p12.get(); + + if (!p12_ptr_) throw std::runtime_error("Insufficient configuration to create certificate"); + + // Make a backup of the existing P12 file if it exists + backupFileIfExists(filename_); + + // Open file for writing. + file_ptr file(fopen(filename_.c_str(), "wb"), false); + if (!file) { + throw std::runtime_error(SB() << "Error opening P12 file for writing" << filename_); + } + + // Write PKCS12 object to file + if (i2d_PKCS12_fp(file.get(), p12_ptr_) != 1) throw std::runtime_error(SB() << "Error writing " << usage_ << " data to file: " << filename_); + + // flush the output to the file + fflush(file.get()); + + // close the file + fclose(file.get()); + + p12_ptr_ = nullptr; + p12.release(); // Free up p12 object + file.release(); // Close file and release pointer + + chmod(filename_.c_str(), + S_IRUSR | S_IWUSR); // Protect P12 file + log_info_printf(certs, "%s file Created: %s\n", usage_.c_str(), filename_.c_str()); +} +} // namespace certs +} // namespace pvxs diff --git a/certs/p12filefactory.h b/certs/p12filefactory.h new file mode 100644 index 000000000..47dfff111 --- /dev/null +++ b/certs/p12filefactory.h @@ -0,0 +1,123 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_P12_FILE_FACTORY_H +#define PVXS_P12_FILE_FACTORY_H + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "certfilefactory.h" +#include "ownedptr.h" +#include "security.h" + +namespace pvxs { +namespace certs { + +/** + * @class KeychainFactory + * + * @brief Manages certificates and associated operations. + */ +class P12FileFactory : public IdFileFactory { + public: + P12FileFactory(const std::string &filename, const std::string &password, const std::shared_ptr &key_pair, bool certs_only = false) + : IdFileFactory(filename, password, key_pair, nullptr, nullptr, "private key", "", certs_only), p12_ptr_(nullptr) {} + + P12FileFactory(const std::string &filename, const std::string &password, const std::shared_ptr &key_pair, X509 *cert_ptr, stack_st_X509 *certs_ptr, + bool certs_only = false) + : IdFileFactory(filename, password, key_pair, cert_ptr, certs_ptr, "certificate", "", certs_only), p12_ptr_(nullptr) {} + + P12FileFactory(const std::string &filename, const std::string &password, const std::shared_ptr &key_pair, const std::string &pem_string, + bool certs_only = false) + : IdFileFactory(filename, password, key_pair, nullptr, nullptr, "certificate", pem_string, certs_only), p12_ptr_(nullptr) {} + + P12FileFactory(const std::string &filename, const std::string &password, const std::shared_ptr &key_pair, PKCS12 *p12_ptr, bool certs_only = false) + : IdFileFactory(filename, password, key_pair, nullptr, nullptr, "certificate", "", certs_only), p12_ptr_(p12_ptr) {} + + void writePKCS12File(); + + void writeIdentityFile() override { writePKCS12File(); } + + CertData getCertDataFromFile() override; + std::shared_ptr getKeyFromFile() override; + + private: + PKCS12 *p12_ptr_{}; + + static ossl_ptr pemStringToP12(std::string password, EVP_PKEY *keys_ptr, std::string pem_string, bool certs_only = false); + + static ossl_ptr toP12(std::string password, EVP_PKEY *keys_ptr, X509 *cert_ptr, STACK_OF(X509) *cert_chain_ptr = nullptr, bool certs_only = false); + +#ifdef NID_oracle_jdk_trustedkeyusage + /** + * @brief Add the JDK trusted key usage attribute to the p12 object + * + * This is done by using the callback mechanism that is triggered by PKCS12_create_ex2 for every bag. + * We can then ignore all bags except X509 certificates with an associated key. + * + * This is conditionally compiled in for platforms that support it. + * + * @param bag the p12 safe bag to add the attribute to + * @param cbarg the callback argument (not used) + * @return 1 if the attribute was added, 0 if it was not added + */ + static int jdkTrust(PKCS12_SAFEBAG *bag, void *cbarg) noexcept { + try { + // Only add trustedkeyusage when bag is an X509 cert. with an + // associated key (when localKeyID is present) which does not + // already have trustedkeyusage. + if (PKCS12_SAFEBAG_get_nid(bag) != NID_certBag || PKCS12_SAFEBAG_get_bag_nid(bag) != NID_x509Certificate || + !!PKCS12_SAFEBAG_get0_attr(bag, NID_localKeyID) || !!PKCS12_SAFEBAG_get0_attr(bag, NID_oracle_jdk_trustedkeyusage)) + return 1; + + auto curattrs(PKCS12_SAFEBAG_get0_attrs(bag)); + // PKCS12_SAFEBAG_get0_attrs() returns const. Make a paranoia copy. + pvxs::ossl_ptr newattrs(sk_X509_ATTRIBUTE_deep_copy(curattrs, &X509_ATTRIBUTE_dup, &X509_ATTRIBUTE_free)); + + pvxs::ossl_ptr trust(OBJ_txt2obj("anyExtendedKeyUsage", 0)); + pvxs::ossl_ptr attr(X509_ATTRIBUTE_create(NID_oracle_jdk_trustedkeyusage, V_ASN1_OBJECT, trust.get())); + + if (sk_X509_ATTRIBUTE_push(newattrs.get(), attr.get()) != 1) { + std::cerr << "Error: unable to add JDK trust attribute\n"; + return 1; + } + attr.release(); + + PKCS12_SAFEBAG_set0_attrs(bag, newattrs.get()); + newattrs.release(); + + return 1; + } catch (std::exception &e) { + std::cerr << "Error: unable to add JDK trust attribute: " << e.what() << "\n"; + return 0; + } + }; +#else + static int jdkTrust(PKCS12_SAFEBAG *bag, void *cbarg) noexcept { return 0; } + static inline PKCS12 *PKCS12_create_ex2(const char *pass, const char *name, EVP_PKEY *pkey, X509 *cert, STACK_OF(X509) * ca, int nid_key, int nid_cert, + int iter, int mac_iter, int keytype, OSSL_LIB_CTX *ctx, const char *propq, + int (*cb)(PKCS12_SAFEBAG *bag, void *cbarg), void *cbarg) { + return PKCS12_create_ex(pass, name, pkey, cert, ca, nid_key, nid_cert, iter, mac_iter, keytype, ctx, propq); + } +#endif +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_P12_FILE_FACTORY_H diff --git a/certs/pemfilefactory.cpp b/certs/pemfilefactory.cpp new file mode 100644 index 000000000..0e63ddec9 --- /dev/null +++ b/certs/pemfilefactory.cpp @@ -0,0 +1,296 @@ +#include "pemfilefactory.h" + +#include +#include +#include + +#include +#include + +#include "certfactory.h" +#include "openssl.h" + +namespace pvxs { +namespace certs { + +DEFINE_LOGGER(pemcerts, "pvxs.certs.pem"); + +/** + * @brief Create a root PEM file from a PEM string + * + * @param p12PemString the PEM string to convert + * @param overwrite if true then an existing file will be overwritten + * @return true if the file already exists, false otherwise + * @throw std::runtime_error if the file cannot be written + */ +bool PEMFileFactory::createRootPemFile(const std::string& p12_pem_string, bool overwrite) { + static constexpr auto kMaxAuthnNameLen = 256; + + ossl_ptr bio(BIO_new_mem_buf(p12_pem_string.data(), p12_pem_string.size())); + + // Create a stack for the certs + STACK_OF(X509_INFO) * inf(PEM_X509_INFO_read_bio(bio.get(), NULL, NULL, NULL)); + if (!inf || sk_X509_INFO_num(inf) == 0) { + throw std::runtime_error("No certificates found in PEM data"); + } + + // Get the root CA certificate (either the only one or the last in chain) + X509_INFO* xi = nullptr; + int num_certs = sk_X509_INFO_num(inf); + + if (num_certs == 1) { + // Single certificate case (self-signed CA) + xi = sk_X509_INFO_value(inf, 0); + } else { + // Certificate chain case (get the last one) + xi = sk_X509_INFO_value(inf, num_certs - 1); + } + + if (!xi || !xi->x509) { + throw std::runtime_error("Failed to get root certificate"); + } + + // Build filename based on the CA certificate's CN field + ossl_ptr name(X509_get_subject_name(xi->x509), false); + if (!name) { + throw std::runtime_error("Failed to get subject name from certificate"); + } + + char cn[kMaxAuthnNameLen]; + if (X509_NAME_get_text_by_NID(name.get(), NID_commonName, cn, sizeof(cn)) < 0) { + throw std::runtime_error("Failed to get CN from certificate"); + } + + std::string fileName(cn); + std::replace(fileName.begin(), fileName.end(), ' ', '_'); + fileName += ".crt"; + + // Prepare file to write + std::string certs_directory_string = CertFactory::getCertsDirectory(); + std::string certs_file = certs_directory_string + "/" + fileName; + std::string hash_link; + + // Check if file already exists + bool exists = (access(certs_file.c_str(), F_OK) != -1); + if (!overwrite && exists) { + log_debug_printf(pemcerts, "Root Certificate already installed: %s\n", certs_file.c_str()); + } + + // If it exists, and we must overwrite then remove the existing one + if (exists && overwrite) std::remove(certs_file.c_str()); + + // Create if it doesn't exist or we must overwrite + if (!exists || overwrite) { + file_ptr fp(fopen(certs_file.c_str(), "w"), false); + if (!fp) { + throw std::runtime_error(SB() << "Error opening root certificate file for writing: " << certs_file); + } + + if (PEM_write_X509(fp.get(), xi->x509) != 1) { + throw std::runtime_error("Failed to write certificate to file"); + } + + fclose(fp.get()); + fp.release(); + + // Verify the file was written correctly + if (std::ifstream(certs_file).peek() == std::ifstream::traits_type::eof()) { + throw std::runtime_error(SB() << "Certificate file is empty after writing: " << certs_file); + } + + // Create appropriate symlink + hash_link = CertFactory::createCertSymlink(certs_file); + } + + // if the certificate is trusted then return true + // Should be already trusted because we've copied it to a trusted location, + // but just in case we need to make sure before continuing + try { + auto cert_data = IdFileFactory::create(certs_file)->getCertDataFromFile(); + ossl::ensureTrusted(cert_data.cert, nullptr); + log_info_printf(pemcerts, "Root CA certificate installed at: %s\n", certs_file.c_str()); + return true; + } catch (std::exception& e) { + log_warn_printf(pemcerts, "Root CA certificate is UNTRUSTED: %s\n", e.what()); + } + +#if defined(__linux__) + log_warn_printf(pemcerts, "To trust this Root CA on Linux:%s", "\n"); + log_warn_printf(pemcerts, "1. Debian/Ubuntu:%s", "\n"); + log_warn_printf(pemcerts, " sudo cp %s /usr/local/share/ca-certificates/\n", certs_file.c_str()); + log_warn_printf(pemcerts, " sudo update-ca-certificates%s", "\n"); + log_warn_printf(pemcerts, "2. RHEL/CentOS:%s", "\n"); + log_warn_printf(pemcerts, " sudo cp %s /etc/pki/ca-trust/source/anchors/\n", certs_file.c_str()); + log_warn_printf(pemcerts, " sudo update-ca-trust%s", "\n"); + +#elif defined(__APPLE__) + log_warn_printf(pemcerts, "To trust this Root CA on macOS:%s", "\n"); + log_warn_printf(pemcerts, "1. Add to System Keychain:%s", "\n"); + log_warn_printf(pemcerts, " sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s\n", certs_file.c_str()); + log_warn_printf(pemcerts, "2. Create hash symlink:%s", "\n"); + log_warn_printf(pemcerts, " sudo ln -sf %s /etc/ssl/certs/%s\n", certs_file.c_str(), hash_link.c_str()); + +#elif defined(_WIN32) + log_warn_printf(pemcerts, "To trust this Root CA on Windows:%s", "\n"); + log_warn_printf(pemcerts, "1. Double-click %s\n", certs_file.c_str()); + log_warn_printf(pemcerts, "2. Click 'Install Certificate'%s", "\n"); + log_warn_printf(pemcerts, "3. Select 'Local Machine' and click 'Next'%s", "\n"); + log_warn_printf(pemcerts, "4. Select 'Place all certificates in the following store'%s", "\n"); + log_warn_printf(pemcerts, "5. Click 'Browse' and select 'Trusted Root Certification Authorities'%s", "\n"); + log_warn_printf(pemcerts, "6. Click 'Next' and then 'Finish'%s", "\n"); + +#elif defined(__rtems__) + log_warn_printf(pemcerts, "For RTEMS systems:%s", "\n"); + log_warn_printf(pemcerts, "Ensure %s is included in your SSL certificate directory\n", certs_file.c_str()); + log_warn_printf(pemcerts, "Hash link: %s\n", hash_link.c_str()); +#endif + + return false; +} + +/** + * @brief Write the PEM file and set permissions to protect it + * + * @throw std::runtime_error if the file cannot be written + */ +void PEMFileFactory::writePEMFile() { + // Backup existing file if necessary + backupFileIfExists(filename_); + + // Open file for writing + file_ptr fp(fopen(filename_.c_str(), "w"), false); + if (!fp) { + throw std::runtime_error(SB() << "Error opening certificate file for writing: " << filename_); + } + + if (!pem_string_.empty()) { + // Write the PEM string directly + if (fputs(pem_string_.c_str(), fp.get()) == EOF) { + throw std::runtime_error("Failed to write PEM string to file"); + } + } else if (cert_ptr_) { + // Write the certificate + if (PEM_write_X509(fp.get(), cert_ptr_) != 1) { + throw std::runtime_error("Failed to write certificate to file"); + } + + // Write the certificate chain + if (certs_ptr_) { + for (int i = 0; i < sk_X509_num(certs_ptr_); i++) { + X509* chain_cert = sk_X509_value(certs_ptr_, i); + if (PEM_write_X509(fp.get(), chain_cert) != 1) { + throw std::runtime_error("Failed to write certificate chain to file"); + } + } + } + + // Write private key if available + if (key_pair_ && key_pair_->pkey) { + if (!password_.empty()) { + // Write encrypted private key using PKCS8 format + const EVP_CIPHER* cipher = EVP_aes_256_cbc(); + if (PEM_write_PKCS8PrivateKey(fp.get(), key_pair_->pkey.get(), cipher, nullptr, 0, nullptr, const_cast(password_.c_str())) != 1) { + throw std::runtime_error("Failed to write encrypted private key"); + } + } else { + // Write unencrypted private key + if (PEM_write_PrivateKey(fp.get(), key_pair_->pkey.get(), nullptr, nullptr, 0, nullptr, nullptr) != 1) { + throw std::runtime_error("Failed to write private key"); + } + } + } + } else { + throw std::runtime_error("No certificate or PEM string available to write"); + } + + chmod(filename_.c_str(), S_IRUSR | S_IWUSR); // Protect PEM file + log_info_printf(pemcerts, "Certificate file created: %s\n", filename_.c_str()); +} + +/** + * @brief Get the certificate data from a PEM file + * + * @param filename the path to the PEM file + * @return a CertData object containing the certificate and the chain + * @throw std::runtime_error if the file cannot be opened or read + */ +CertData PEMFileFactory::getCertDataFromFile() { + file_ptr fp(fopen(filename_.c_str(), "rb"), false); + if (!fp) { + throw std::runtime_error(SB() << "Error opening certificate file: " << filename_); + } + + // Read the first certificate (main cert) + ossl_ptr cert(PEM_read_X509(fp.get(), nullptr, nullptr, nullptr), false); + if (!cert) { + throw std::runtime_error(SB() << "Error reading certificate from file: " << filename_); + } + + // Read any additional certificates (chain) + ossl_shared_ptr chain(sk_X509_new_null()); + if (!chain) { + throw std::runtime_error("Unable to allocate certificate chain"); + } + + ossl_ptr ca; + while (X509* ca_ptr = PEM_read_X509(fp.get(), nullptr, nullptr, nullptr)) { + ca = ossl_ptr(ca_ptr); + if (sk_X509_push(chain.get(), ca.get()) != 1) { + throw std::runtime_error("Failed to add certificate to chain"); + } + ca.release(); + } + + // Clear any end-of-file errors + ERR_clear_error(); + + // Read any private key + std::shared_ptr key_pair; + + // Try to read the private key + try { + ossl_ptr pkey; + if (!password_.empty()) { + // Use password if available + pkey.reset(PEM_read_PrivateKey(fp.get(), nullptr, nullptr, const_cast(password_.c_str()))); + } else { + // Try reading without password + pkey.reset(PEM_read_PrivateKey(fp.get(), nullptr, nullptr, nullptr)); + } + + // Try to get key from file if it is configured + if (!pkey && key_file_) { + key_pair = key_file_->getKeyFromFile(); + pkey = std::move(key_pair->pkey); + } + if (pkey) return CertData(cert, chain, std::make_shared(std::move(pkey))); + } catch (...) { + } + + return CertData(cert, chain); +} + +/** + * @brief Get a key pair from a PEM file + * + * @return a shared pointer to the KeyPair object + * @throw std::runtime_error if the file cannot be opened or read + */ +std::shared_ptr PEMFileFactory::getKeyFromFile() { + file_ptr fp(fopen(filename_.c_str(), "r"), false); + if (!fp) { + throw std::runtime_error(SB() << "Error opening private key file: \"" << filename_ << "\""); + } + + // Try to read the private key + ossl_ptr pkey(PEM_read_PrivateKey(fp.get(), nullptr, nullptr, nullptr), false); + if (!pkey) { + ERR_clear_error(); + throw std::runtime_error(SB() << "No private key found in file: " << filename_); + } + + return std::make_shared(std::move(pkey)); +} + +} // namespace certs +} // namespace pvxs diff --git a/certs/pemfilefactory.h b/certs/pemfilefactory.h new file mode 100644 index 000000000..f29e3e094 --- /dev/null +++ b/certs/pemfilefactory.h @@ -0,0 +1,37 @@ +#ifndef PVXS_PEM_FILE_FACTORY_H +#define PVXS_PEM_FILE_FACTORY_H + +#include "certfilefactory.h" +#include "ownedptr.h" +#include "security.h" + +namespace pvxs { +namespace certs { + +class PEMFileFactory : public IdFileFactory { + public: + explicit PEMFileFactory(const std::string& filename, const std::string& password = "", const std::shared_ptr& key_pair = nullptr) + : IdFileFactory(filename, password, key_pair), password_(password) {} + explicit PEMFileFactory(const std::string& filename, const std::string& password = "", const std::shared_ptr& key_pair = nullptr, + X509* cert_ptr = nullptr, STACK_OF(X509) * certs_ptr = nullptr, bool certs_only = false) + : IdFileFactory(filename, password, key_pair, cert_ptr, certs_ptr, "certificate", "", certs_only), password_(password) {} + explicit PEMFileFactory(const std::string& filename, const std::string& password = "", const std::shared_ptr& key_pair = nullptr, + const std::string& pem_string = "", bool certs_only = false) + : IdFileFactory(filename, password, key_pair, nullptr, nullptr, "certificate", pem_string, certs_only), password_(password) {} + + static bool createRootPemFile(const std::string& pemString, bool overwrite = false); + + std::shared_ptr getKeyFromFile() override; + CertData getCertDataFromFile() override; + + void writeIdentityFile() override { writePEMFile(); } + void writePEMFile(); + + private: + const std::string password_{}; +}; + +} // namespace certs +} // namespace pvxs + +#endif diff --git a/certs/pvacms.cpp b/certs/pvacms.cpp new file mode 100644 index 000000000..17d2afe85 --- /dev/null +++ b/certs/pvacms.cpp @@ -0,0 +1,1885 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The PVAccess Certificate Management Service. + * + * pvacms + * + */ + +#include "pvacms.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "certfactory.h" +#include "certfilefactory.h" +#include "certstatus.h" +#include "certstatusfactory.h" +#include "configcms.h" +#include "evhelper.h" +#include "openssl.h" +#include "ownedptr.h" +#include "sqlite3.h" +#include "utilpvt.h" +#include "securityclient.h" +#include "credentials.h" + +DEFINE_LOGGER(pvacms, "pvxs.certs.cms"); +DEFINE_LOGGER(pvacmsmonitor, "pvxs.certs.stat"); +DEFINE_LOGGER(pvafms, "pvxs.certs.fms"); + +namespace pvxs { +namespace certs { + +struct ASMember { + std::string name; + ASMEMBERPVT mem; + ASMember() : ASMember("DEFAULT") {} + ASMember(const std::string &n) : name(n) { + if (asAddMember(&mem, name.c_str())) + throw std::runtime_error(SB() << "Unable to create ASMember " << n); + // mem references name.c_str() + } + ~ASMember() { + // all clients must be disconnected... + if (asRemoveMember(&mem)) + log_err_printf(pvacms, "Unable to cleanup ASMember %s\n", name.c_str()); + } +}; + +/** + * @brief These are the cumulative total days in a year at the start of each month, + * + * for example: + * January 1st is the 0th day of the year (month_start_days[0] = 0) + * February 1st is the 31st day of the year (month_start_days[1] = 31) + * March 1st is the 59th (month_start_days[2] = 59) + * and so on. + * + * This array does not consider leap years + */ +static const std::string kCertRoot("CERT:ROOT"); + +// The current partition number +uint16_t partition_number = 0; + +// The current number of partitions +uint16_t num_partitions = 1; + +// Forward decls + +/** + * @brief The prototype of the returned data from a create certificate operation + * @return the prototype to use for create certificate operations + */ +Value getCreatePrototype() { + using namespace members; + nt::NTEnum enum_value; + auto value = TypeDef(TypeCode::Struct, + { + enum_value.build().as("status"), + Member(TypeCode::UInt64, "serial"), + Member(TypeCode::String, "state"), + Member(TypeCode::String, "issuer"), + Member(TypeCode::String, "certid"), + Member(TypeCode::String, "statuspv"), + Member(TypeCode::String, "cert"), + Struct("alarm", "alarm_t", + { + Int32("severity"), + Int32("status"), + String("message"), + }), + }) + .create(); + shared_array choices(CERT_STATES); + value["status.value.choices"] = choices.freeze(); + return value; +} + +/** + * @brief The value for a GET root certificate operation + * @return The value for a GET root certificate operation + */ +Value getRootValue(const std::string &issuer_id, const ossl_ptr &ca_cert, const ossl_shared_ptr &ca_chain) { + using namespace members; + auto value = TypeDef(TypeCode::Struct, + { + Member(TypeCode::UInt64, "serial"), + Member(TypeCode::String, "issuer"), + Member(TypeCode::String, "name"), + Member(TypeCode::String, "org"), + Member(TypeCode::String, "org_unit"), + Member(TypeCode::String, "cert"), + Struct("alarm", "alarm_t", + { + Int32("severity"), + Int32("status"), + String("message"), + }), + }) + .create(); + auto subject_name(X509_get_subject_name(ca_cert.get())); + auto subject_string(X509_NAME_oneline(subject_name, nullptr, 0)); + ossl_ptr owned_subject(subject_string, false); + if (!owned_subject) { + throw std::runtime_error("Unable to get the subject of the CA certificate"); + } + std::string subject(owned_subject.get()); + + // Subject part extractor + auto extractSubjectPart = [&subject](const std::string &key) -> std::string { + std::size_t start = subject.find("/" + key + "="); + if (start == std::string::npos) { + throw std::runtime_error("Key not found: " + key); + } + start += key.size() + 2; // Skip over "/key=" + std::size_t end = subject.find("/", start); // Find the end of the current value + if (end == std::string::npos) { + end = subject.size(); + } + return subject.substr(start, end - start); + }; + + value["serial"] = pvxs::certs::CertStatusFactory::getSerialNumber(ca_cert); + value["issuer"] = issuer_id; + value["name"] = extractSubjectPart("CN"); + value["org"] = extractSubjectPart("O"); + value["org_unit"] = extractSubjectPart("OU"); + value["cert"] = CertFactory::certAndCasToPemString(ca_cert, ca_chain.get()); + + return value; +} + +/** + * @brief Initializes the certificates database by opening the specified + * database file. + * + * @param ca_db A shared pointer to the SQLite database object. + * @param db_file The path to the SQLite database file. + * + * @throws std::runtime_error if the database can't be opened or initialised + */ +void initCertsDatabase(sql_ptr &ca_db, std::string &db_file) { + if ((sqlite3_open(db_file.c_str(), ca_db.acquire()) != SQLITE_OK)) { + throw std::runtime_error(SB() << "Can't open certs db file: " << sqlite3_errmsg(ca_db.get())); + } else { + int rc = sqlite3_exec(ca_db.get(), SQL_CREATE_DB_FILE, 0, 0, 0); + if (rc != SQLITE_OK && rc != SQLITE_DONE) { + throw std::runtime_error(SB() << "Can't initialise certs db file: " << sqlite3_errmsg(ca_db.get())); + } + } +} + +/** + * @brief Retrieves the status of a certificate from the database. + * + * This function retrieves the status of a certificate with the given serial + * number from the specified database. + * + * @param ca_db A reference to the SQLite database connection. + * @param serial The serial number of the certificate. + * + * @return The status of the certificate. + * + * @throw std::runtime_error If there is an error preparing the SQL statement or + * retrieving the certificate status. + */ +std::tuple getCertificateStatus(sql_ptr &ca_db, uint64_t serial) { + int cert_status = UNKNOWN; + time_t status_date = std::time(nullptr); + + int64_t db_serial = *reinterpret_cast(&serial); + sqlite3_stmt *sql_statement; + if (sqlite3_prepare_v2(ca_db.get(), SQL_CERT_STATUS, -1, &sql_statement, 0) == SQLITE_OK) { + sqlite3_bind_int64(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":serial"), db_serial); + + if (sqlite3_step(sql_statement) == SQLITE_ROW) { + cert_status = sqlite3_column_int(sql_statement, 0); + status_date = sqlite3_column_int64(sql_statement, 1); + } + } else { + sqlite3_finalize(sql_statement); + throw std::logic_error(SB() << "failed to prepare sqlite statement: " << sqlite3_errmsg(ca_db.get())); + } + + return std::make_tuple((certstatus_t)cert_status, status_date); +} + +std::tuple getCertificateValidity(sql_ptr &ca_db, uint64_t serial) { + time_t not_before, not_after; + + int64_t db_serial = *reinterpret_cast(&serial); + sqlite3_stmt *sql_statement; + if (sqlite3_prepare_v2(ca_db.get(), SQL_CERT_VALIDITY, -1, &sql_statement, 0) == SQLITE_OK) { + sqlite3_bind_int64(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":serial"), db_serial); + + if (sqlite3_step(sql_statement) == SQLITE_ROW) { + not_before = sqlite3_column_int64(sql_statement, 0); + not_after = sqlite3_column_int64(sql_statement, 1); + } + } else { + sqlite3_finalize(sql_statement); + throw std::logic_error(SB() << "failed to prepare sqlite statement: " << sqlite3_errmsg(ca_db.get())); + } + + return std::make_tuple(not_before, not_after); +} + +/** + * @brief Generates a SQL clause for filtering valid certificate statuses. + * + * This function takes a vector of CertStatus values and generates a SQL clause that can be used to filter + * records with matching statuses. Each status value in the vector is converted into a parameterized condition in the clause. + * The generated clause starts with "AND (" and ends with " )" and contains multiple "OR" conditions for each status value. + * + * @param valid_status The vector of CertStatus values to be filtered. + * @return A string representing the SQL clause for filtering valid certificate statuses. If the vector is empty, an empty string is returned. + */ +std::string getValidStatusesClause(const std::vector valid_status) { + auto n_valid_status = valid_status.size(); + if (n_valid_status > 0) { + auto valid_status_clauses = SB(); + valid_status_clauses << " AND ("; + for (auto i = 0; i < n_valid_status; i++) { + if (i != 0) valid_status_clauses << " OR"; + valid_status_clauses << " status = :status" << i; + } + valid_status_clauses << " )"; + return valid_status_clauses.str(); + } + return ""; +} + +/** + * Binds the valid certificate status clauses to the given SQLite statement. + * + * @param sql_statement The SQLite statement to bind the clauses to. + * @param valid_status A vector containing the valid certificate status values. + */ +void bindValidStatusClauses(sqlite3_stmt *sql_statement, const std::vector valid_status) { + auto n_valid_status = valid_status.size(); + for (auto i = 0; i < n_valid_status; i++) { + sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, (SB() << ":status" << i).str().c_str()), valid_status[i]); + } +} + +/** + * @brief Updates the status of a certificate in the certificates database. + * + * This function updates the status of a certificate in the certificates database. + * The status is specified by the CertStatus enum. The function compares + * the specified certificate's status with the valid_status vector to ensure that + * only certificates that are already in one of those states are allowed to move + * to the new status. If the existing status is valid, it updates the status of the + * certificate associated with the specified serial number to the new status. + * + * @param ca_db A reference to the certificates database, represented as a sql_ptr object. + * @param serial The serial number of the certificate to update. + * @param cert_status The new status to set for the certificate. + * @param valid_status A vector containing the valid status values that are allowed to transition a certificate from. + * + * @return None + */ +epicsMutex status_update_lock; +void updateCertificateStatus(sql_ptr &ca_db, uint64_t serial, certstatus_t cert_status, int approval_status, const std::vector valid_status) { + int64_t db_serial = *reinterpret_cast(&serial); + sqlite3_stmt *sql_statement; + int sql_status; + std::string sql(approval_status == -1 ? SQL_CERT_SET_STATUS : SQL_CERT_SET_STATUS_W_APPROVAL); + sql += getValidStatusesClause(valid_status); + auto current_time = std::time(nullptr); + Guard G(status_update_lock); + if ((sql_status = sqlite3_prepare_v2(ca_db.get(), sql.c_str(), -1, &sql_statement, 0)) == SQLITE_OK) { + sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":status"), cert_status); + if (approval_status >= 0) sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":approved"), approval_status); + sqlite3_bind_int64(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":status_date"), current_time); + sqlite3_bind_int64(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":serial"), db_serial); + bindValidStatusClauses(sql_statement, valid_status); + sql_status = sqlite3_step(sql_statement); + } + sqlite3_finalize(sql_statement); + + // Check the number of rows affected + if (sql_status == SQLITE_DONE) { + int rows_affected = sqlite3_changes(ca_db.get()); + if (rows_affected == 0) { + throw std::runtime_error("No certificate found"); + } + } else { + throw std::runtime_error(SB() << "Failed to set cert status: " << sqlite3_errmsg(ca_db.get())); + } +} + +/** + * @brief Generates a random serial number. + * + * This function generates a random serial number using the Mersenne Twister + * algorithm. The generated serial number is a 64-bit unsigned integer. + * + * @return The generated serial number. + * + * @note The random number generator is seeded with a random value from + * hardware. It is important to note that the quality of the randomness may vary + * depending on the hardware and operating system. + */ +uint64_t generateSerial() { + std::random_device random_from_device; // Obtain a random number from hardware + std::mt19937_64 seed(random_from_device()); // Seed the generator + std::uniform_int_distribution distribution; // Define the range + + uint64_t random_serial_number = distribution(seed); // Generate a random number + return random_serial_number; +} + +/** + * @brief Store the certificate in the database + * + * This function stores the certificate details in the database provided + * + * @param[in] ca_db The SQL database connection + * @param[in] cert_factory The certificate factory used to build the certificate + * @return effective certificate status stored + * + * @throws std::runtime_error If failed to create the certificate in the + * database + */ +certstatus_t storeCertificate(sql_ptr &ca_db, CertFactory &cert_factory) { + auto db_serial = *reinterpret_cast(&cert_factory.serial_); // db stores as signed int so convert to and from + auto current_time = std::time(nullptr); + auto effective_status = cert_factory.initial_status_ != VALID ? cert_factory.initial_status_ + : current_time < cert_factory.not_before_ ? PENDING + : current_time >= cert_factory.not_after_ ? EXPIRED + : cert_factory.initial_status_; + + checkForDuplicates(ca_db, cert_factory); + + sqlite3_stmt *sql_statement; + auto sql_status = sqlite3_prepare_v2(ca_db.get(), SQL_CREATE_CERT, -1, &sql_statement, NULL); + if (sql_status == SQLITE_OK) { + sqlite3_bind_int64(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":serial"), db_serial); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":skid"), cert_factory.skid_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":CN"), cert_factory.name_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":O"), cert_factory.org_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":OU"), cert_factory.org_unit_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":C"), cert_factory.country_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":not_before"), (int)cert_factory.not_before_); + sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":not_after"), (int)cert_factory.not_after_); + sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":status"), effective_status); + sqlite3_bind_int(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":approved"), cert_factory.initial_status_ == VALID ? 1 : 0); + sqlite3_bind_int64(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":status_date"), current_time); + + sql_status = sqlite3_step(sql_statement); + } + + sqlite3_finalize(sql_statement); + + if (sql_status != SQLITE_OK && sql_status != SQLITE_DONE) { + throw std::runtime_error(SB() << "Failed to create certificate: " << sqlite3_errmsg(ca_db.get())); + } + return effective_status; +} + +/** + * @brief Checks for duplicates between certificates in the given database and the certificate that will be generated by the given certificate factory. + * + * This function takes a reference to a `sql_ptr` object representing a database + * and a reference to a `CertFactory` object. It checks for duplicates in the + * database by comparing the subject of the certificate that would be generated by the + * certificate factory with the ones in the database and by comparing the subject key identifier + * that would be produced by the certificate factory with any that are already present in the + * database. If any duplicates are found, they are handled according + * to the specified business logic. + * + * Certificates that are pending and pending approval are also included. So a new certificate + * that matches any certificates that are not yet valid (pending) or are awaiting + * administrator approval (pending approval) will be rejected. + * + * @param ca_db A reference to a `sql_ptr` object representing the database to check for duplicates. + * @param cert_factory A reference to a `CertFactory` object containing the certificate configuration to compare against the database. + * + * @return void + * + * @remark This function assumes that the database and certificate factory objects are properly initialized and accessible. + * It does not handle any exceptions or errors that might occur during the duplicate checking process. + * Users of this function should ensure that any required error handling and exception handling is implemented accordingly. + */ +void checkForDuplicates(sql_ptr &ca_db, CertFactory &cert_factory) { + // Prepare SQL statements + sqlite3_stmt *sql_statement; + + const std::vector valid_status{VALID, PENDING_APPROVAL, PENDING}; + + // Check for duplicate subject + std::string subject_sql(SQL_DUPS_SUBJECT); + subject_sql += getValidStatusesClause(valid_status); + if (sqlite3_prepare_v2(ca_db.get(), subject_sql.c_str(), -1, &sql_statement, nullptr) != SQLITE_OK) { + throw std::runtime_error("Failed to prepare statement"); + } + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":CN"), cert_factory.name_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":O"), cert_factory.org_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":OU"), cert_factory.org_unit_.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":C"), cert_factory.country_.c_str(), -1, SQLITE_STATIC); + bindValidStatusClauses(sql_statement, valid_status); + auto subject_dup_status = sqlite3_step(sql_statement) == SQLITE_ROW && sqlite3_column_int(sql_statement, 0) > 0; + sqlite3_finalize(sql_statement); + if (subject_dup_status) { + throw std::runtime_error(SB() << "Duplicate Certificate Subject: cn=" << cert_factory.name_ << ", o=" << cert_factory.org_ + << ", ou=" << cert_factory.org_unit_ << ", c=" << cert_factory.country_); + } + + // Check for duplicate SKID + std::string subject_key_sql(SQL_DUPS_SUBJECT_KEY_IDENTIFIER); + subject_key_sql += getValidStatusesClause(valid_status); + if (sqlite3_prepare_v2(ca_db.get(), subject_key_sql.c_str(), -1, &sql_statement, nullptr) != SQLITE_OK) { + throw std::runtime_error("Failed to prepare statement"); + } + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":skid"), cert_factory.skid_.c_str(), -1, SQLITE_STATIC); + bindValidStatusClauses(sql_statement, valid_status); + + auto skid_dup_status = sqlite3_step(sql_statement) == SQLITE_ROW && sqlite3_column_int(sql_statement, 0) > 0; + sqlite3_finalize(sql_statement); + if (skid_dup_status) { + throw std::runtime_error("Duplicate Certificate Subject Key Identifier. Best-practices require use of a distinct Key-Pair for each certificate"); + } +} + +/** + * @brief The function that does the actual certificate creation in PVACMS + * + * Dont forget to cleanup `chain_ptr` after use with sk_X509_free() + * + * @param ca_db the database to write the certificate to + * @param certificate_factory the certificate factory to use to build the certificate + * + * @return the PEM string that contains the Cert, its chain and the root cert + */ +ossl_ptr createCertificate(sql_ptr &ca_db, CertFactory &certificate_factory) { + // Check validity falls within acceptable range + if (certificate_factory.issuer_certificate_ptr_) ensureValidityCompatible(certificate_factory); + + auto certificate = certificate_factory.create(); + + // Store certificate in database + auto effective_status = storeCertificate(ca_db, certificate_factory); + + // Print info about certificate creation + std::string from = std::ctime(&certificate_factory.not_before_); + std::string to = std::ctime(&certificate_factory.not_after_); + + log_info_printf(pvacms, "--------------------------------------%s", "\n"); + auto cert_description = (SB() << "X.509 " + << (IS_USED_FOR_(certificate_factory.usage_, ssl::kForIntermediateCa) + ? "INTERMEDIATE CA" + : (IS_USED_FOR_(certificate_factory.usage_, ssl::kForClientAndServer) + ? "CLIENT & SERVER" + : (IS_USED_FOR_(certificate_factory.usage_, ssl::kForClient) + ? "CLIENT" + : (IS_USED_FOR_(certificate_factory.usage_, ssl::kForServer) + ? "SERVER" + : (IS_USED_FOR_(certificate_factory.usage_, ssl::kForCMS) + ? "PVACMS" + : (IS_USED_FOR_(certificate_factory.usage_, ssl::kForCa) ? "CA" : "STRANGE")))))) + << " certificate") + .str(); + log_info_printf(pvacms, "%s\n", cert_description.c_str()); + log_info_printf( + pvacms, "%s\n", + (SB() << "CERT_ID: " << getCertId(CertStatus::getIssuerId(certificate_factory.issuer_certificate_ptr_), certificate_factory.serial_)).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "NAME: " << certificate_factory.name_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "ORGANIZATION: " << certificate_factory.org_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "ORGANIZATIONAL UNIT: " << certificate_factory.org_unit_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "COUNTRY: " << certificate_factory.country_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "STATUS: " << CERT_STATE(effective_status)).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "VALIDITY: " << from.substr(0, from.size() - 1) << " to " << to.substr(0, to.size() - 1)).str().c_str()); + log_info_printf(pvacms, "--------------------------------------%s", "\n"); + + return certificate; +} + +/** + * @brief Creates a PEM string representation of a certificate. + * + * This function creates a PEM string representation of a certificate by creating the certificate using the provided + * CA database and certificate factory, and then converting the certificate and CA chain to PEM format. + * + * @param ca_db The CA database. + * @param cert_factory The certificate factory. + * @return A PEM string representation of the certificate. + */ +std::string createCertificatePemString(sql_ptr &ca_db, CertFactory &cert_factory) { + ossl_ptr cert; + + cert = createCertificate(ca_db, cert_factory); + + // Write out as PEM string for return to client + return CertFactory::certAndCasToPemString(cert, cert_factory.certificate_chain_.get()); +} + +/** + * This function is used to retrieve the value of a specified field from a given structure. + * + * @param src The structure from which to retrieve the field value. + * @param field The name of the field whose value should be retrieved. + * @return The value of the specified field in the given structure. + * + * @note This function assumes that the specified field exists in the structure and can be accessed using the dot notation. + * @warning If the specified field does not exist or cannot be accessed, the function will throw a field not found exception. + * @attention This function does not modify the given structure or its fields. + * @see setStructureValue() + */ +template +T getStructureValue(const Value &src, const std::string &field) { + auto value = src[field]; + if (!value) { + throw std::runtime_error(SB() << field << " field not provided"); + } + return value.as(); +} + +bool getPriorApprovalStatus(sql_ptr &ca_db, std::string &name, std::string &country, std::string &organization, std::string &organization_unit) { + // Check for duplicate subject + sqlite3_stmt *sql_statement; + bool previously_approved{false}; + + std::string approved_sql(SQL_PRIOR_APPROVAL_STATUS); + if (sqlite3_prepare_v2(ca_db.get(), approved_sql.c_str(), -1, &sql_statement, nullptr) != SQLITE_OK) { + throw std::runtime_error("Failed to prepare statement"); + } + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":CN"), name.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":O"), organization.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":OU"), organization_unit.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(sql_statement, sqlite3_bind_parameter_index(sql_statement, ":C"), country.c_str(), -1, SQLITE_STATIC); + + if (sqlite3_step(sql_statement) == SQLITE_ROW) { + previously_approved = sqlite3_column_int(sql_statement, 0) == 1; + } + + return previously_approved; +} + +/** + * @brief CERT:CREATE Handles the creation of a certificate. + * + * This function handles the creation of a certificate based on the provided + * certificate creation request (ccr). It extracts the necessary information + * from the ccr, creates a reply containing the certificate data, and sends it + * back to the client. + * + * @param pv The shared PV object. + * @param op The unique pointer to the execution operation. + * @param ccr The certificate creation request (input) value. + */ +void onCreateCertificate(ConfigCms &config, sql_ptr &ca_db, const server::SharedPV &pv, std::unique_ptr &&op, Value &&args, + const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, const ossl_ptr &ca_pub_key, + const ossl_shared_ptr &ca_chain, std::string issuer_id) { + auto ccr = args["query"]; + + auto type = getStructureValue(ccr, "type"); + auto name = getStructureValue(ccr, "name"); + auto organization = getStructureValue(ccr, "organization"); + auto usage = getStructureValue(ccr, "usage"); + + try { + certstatus_t state = UNKNOWN; + // Call the authenticator specific verifier if not the default type + if (type.compare(PVXS_DEFAULT_AUTH_TYPE) != 0) { + /* + const auto &authenticator = KeychainFactory::getAuth(type); + if (!authenticator->verify(ccr, + [&ca_pub_key](const std::string &data, + const std::string &signature) { + return CertFactory::verifySignature( + ca_pub_key, + data, + signature); + })) { + throw std::runtime_error("CCR claims are invalid"); + } + */ + state = VALID; + } else { + state = PENDING_APPROVAL; + if ((IS_USED_FOR_(usage, ssl::kForClientAndServer) && !config.cert_gateway_require_approval) || + (IS_USED_FOR_(usage, ssl::kForClient) && !config.cert_client_require_approval) || + (IS_USED_FOR_(usage, ssl::kForServer) && !config.cert_server_require_approval)) { + state = VALID; + } + } + + /////////////////// + // Make Certificate + /////////////////// + + // Get Public Key to use + auto public_key = getStructureValue(ccr, "pub_key"); + const auto key_pair = std::make_shared(public_key); + + // Generate a new serial number + auto serial = generateSerial(); + + // Get other certificate parameters from request + auto country = getStructureValue(ccr, "country"); + auto organization_unit = getStructureValue(ccr, "organization_unit"); + auto not_before = getStructureValue(ccr, "not_before"); + auto not_after = getStructureValue(ccr, "not_after"); + + // If pending approval then check if it has already been approved + if (state == PENDING_APPROVAL) { + if (getPriorApprovalStatus(ca_db, name, country, organization, organization_unit)) { + state = VALID; + } + } + + // Create a certificate factory + auto certificate_factory = CertFactory(serial, key_pair, name, country, organization, organization_unit, not_before, not_after, usage, + config.cert_status_subscription, ca_cert.get(), ca_pkey.get(), ca_chain.get(), state); + + // Create the certificate using the certificate factory, store it in the database and return the PEM string + auto pem_string = createCertificatePemString(ca_db, certificate_factory); + + // Construct and return the reply + auto cert_id = getCertId(issuer_id, serial); + auto status_pv = getCertUri(GET_MONITOR_CERT_STATUS_ROOT, cert_id); + auto reply(getCreatePrototype()); + auto now(time(nullptr)); + reply["status.value.index"] = state; + reply["status.timeStamp.secondsPastEpoch"] = now; + reply["state"] = CERT_STATE(state); + reply["serial"] = serial; + reply["issuer"] = issuer_id; + reply["certid"] = cert_id; + reply["statuspv"] = status_pv; + reply["cert"] = pem_string; + op->reply(reply); + } catch (std::exception &e) { + // For any type of error return an error to the caller + auto cert_name = NAME_STRING(name, organization); + log_err_printf(pvacms, "Failed to create certificate for %s: %s\n", cert_name.c_str(), e.what()); + op->error(SB() << "Failed to create certificate for " << cert_name << ": " << e.what()); + } +} + +/** + * Retrieves the status of the certificate identified by the pv_name. Only called first time + * + * @param ca_db A pointer to the SQL database object. + * @param our_issuer_id The issuer ID of the server. Must match the one provided in pv_name + * @param status_pv The SharedWildcardPV object to store the retrieved status. + * @param pv_name The status pv requested. + * @param parameters The issuer id and serial number strings broken out from the pv_name. + * @param ca_pkey The CA's private key. + * @param ca_cert The CA's certificate. + * @param ca_chain The CA's certificate chain. + * + * @return void + */ +void onGetStatus(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, const std::string &pv_name, + const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain) { + Value status_value(CertStatus::getStatusPrototype()); + uint64_t serial = 0; + static auto cert_status_creator(CertStatusFactory(ca_cert, ca_pkey, ca_chain, config.cert_status_validity_mins)); + try { + std::string issuer_id; + std::tie(issuer_id, serial) = getParameters(parameters); + log_debug_printf(pvacms, "GET STATUS: Certificate %s:%llu\n", issuer_id.c_str(), serial); + + if (our_issuer_id != issuer_id) { + throw std::runtime_error(SB() << "Issuer ID of certificate status requested: " << issuer_id << ", is not our issuer ID: " << our_issuer_id); + } + + // get status value + certstatus_t status; + time_t status_date; + std::tie(status, status_date) = certs::getCertificateStatus(ca_db, serial); + if (status == UNKNOWN) { + throw std::runtime_error("Unable to determine certificate status"); + } + + auto now = std::time(nullptr); + auto cert_status = cert_status_creator.createPVACertificateStatus(serial, status, now, status_date); + postCertificateStatus(status_pv, pv_name, serial, cert_status); + } catch (std::exception &e) { + log_err_printf(pvacms, "PVACMS Error getting status: %s\n", e.what()); + postCertificateStatus(status_pv, pv_name, serial); + } +} + +/** + * Revokes the certificate identified by the pv_name + * + * @param ca_db A pointer to the SQL database object. + * @param our_issuer_id The issuer ID of the server. Must match the one provided in pv_name + * @param status_pv The SharedWildcardPV object to update the status in. + * @param op + * @param pv_name The status PV to be updated to REVOKED. + * @param parameters The issuer id and serial number strings broken out from the pv_name. + * @param ca_pkey The CA's private key. + * @param ca_cert The CA's certificate. + * @param ca_chain The CA's certificate chain. + * + * @return void + */ +void onRevoke(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain) { + Value status_value(CertStatus::getStatusPrototype()); + static auto cert_status_creator(CertStatusFactory(ca_cert, ca_pkey, ca_chain, config.cert_status_validity_mins)); + try { + std::string issuer_id; + uint64_t serial; + std::tie(issuer_id, serial) = getParameters(parameters); + log_debug_printf(pvacms, "REVOKE: Certificate %s:%llu\n", issuer_id.c_str(), serial); + + if (our_issuer_id != issuer_id) { + throw std::runtime_error(SB() << "Issuer ID of certificate status requested: " << issuer_id << ", is not our issuer ID: " << our_issuer_id); + } + + // set status value + certs::updateCertificateStatus(ca_db, serial, REVOKED, 0); + + auto revocation_date = std::time(nullptr); + auto ocsp_status = cert_status_creator.createPVACertificateStatus(serial, REVOKED, revocation_date, revocation_date); + postCertificateStatus(status_pv, pv_name, serial, ocsp_status); + log_info_printf(pvacms, "Certificate %s:%llu has been REVOKED\n", issuer_id.c_str(), serial); + op->reply(); + } catch (std::exception &e) { + log_err_printf(pvacms, "PVACMS Error revoking certificate: %s\n", e.what()); + op->error(SB() << "Error revoking certificate: " << e.what()); + } +} + +/** + * Approves the certificate identified by the pv_name + * + * @param ca_db A pointer to the SQL database object. + * @param our_issuer_id The issuer ID of the server. Must match the one provided in pv_name + * @param status_pv The SharedWildcardPV object to update the status in. + * @param op + * @param pv_name The status PV to be updated to APPROVED. + * @param parameters The issuer id and serial number strings broken out from the pv_name. + * @param ca_pkey The CA's private key. + * @param ca_cert The CA's certificate. + * @param ca_chain The CA's certificate chain. + * + * @return void + */ +void onApprove(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain) { + Value status_value(CertStatus::getStatusPrototype()); + static auto cert_status_creator(CertStatusFactory(ca_cert, ca_pkey, ca_chain, config.cert_status_validity_mins)); + try { + std::string issuer_id; + uint64_t serial; + std::tie(issuer_id, serial) = getParameters(parameters); + log_debug_printf(pvacms, "APPROVE: Certificate %s:%llu\n", issuer_id.c_str(), serial); + + if (our_issuer_id != issuer_id) { + throw std::runtime_error(SB() << "Issuer ID of certificate status requested: " << issuer_id << ", is not our issuer ID: " << our_issuer_id); + } + + // set status value + auto status_date(time(nullptr)); + time_t not_before, not_after; + std::tie(not_before, not_after) = getCertificateValidity(ca_db, serial); + certstatus_t new_state = status_date < not_before ? PENDING : status_date >= not_after ? EXPIRED : VALID; + certs::updateCertificateStatus(ca_db, serial, new_state, 1, {PENDING_APPROVAL}); + + auto cert_status = cert_status_creator.createPVACertificateStatus(serial, new_state, status_date); + postCertificateStatus(status_pv, pv_name, serial, cert_status); + switch (new_state) { + case VALID: + log_info_printf(pvacms, "Certificate %s:%llu has been APPROVED\n", issuer_id.c_str(), serial); + break; + case EXPIRED: + log_info_printf(pvacms, "Certificate %s:%llu has EXPIRED\n", issuer_id.c_str(), serial); + break; + case PENDING: + log_info_printf(pvacms, "Certificate %s:%llu is now PENDING\n", issuer_id.c_str(), serial); + break; + default: + break; + } + op->reply(); + } catch (std::exception &e) { + log_err_printf(pvacms, "PVACMS Error approving certificate: %s\n", e.what()); + op->error(SB() << "Error approving certificate: " << e.what()); + } +} + +/** + * Denies the pending the certificate identified by the pv_name + * + * @param ca_db A pointer to the SQL database object. + * @param our_issuer_id The issuer ID of the server. Must match the one provided in pv_name + * @param status_pv The SharedWildcardPV object to update the status in. + * @param op + * @param pv_name The status PV to be updated to DENIED. + * @param parameters The issuer id and serial number strings broken out from the pv_name. + * @param ca_pkey The CA's private key. + * @param ca_cert The CA's certificate. + * @param ca_chain The CA's certificate chain. + * + * @return void + */ +void onDeny(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain) { + Value status_value(CertStatus::getStatusPrototype()); + static auto cert_status_creator(CertStatusFactory(ca_cert, ca_pkey, ca_chain, config.cert_status_validity_mins)); + try { + std::string issuer_id; + uint64_t serial; + std::tie(issuer_id, serial) = getParameters(parameters); + log_debug_printf(pvacms, "DENY: Certificate %s:%llu\n", issuer_id.c_str(), serial); + + if (our_issuer_id != issuer_id) { + throw std::runtime_error(SB() << "Issuer ID of certificate status requested: " << issuer_id << ", is not our issuer ID: " << our_issuer_id); + } + + // set status value + certs::updateCertificateStatus(ca_db, serial, REVOKED, 0, {PENDING_APPROVAL}); + + auto revocation_date = std::time(nullptr); + auto cert_status = cert_status_creator.createPVACertificateStatus(serial, REVOKED, revocation_date, revocation_date); + postCertificateStatus(status_pv, pv_name, serial, cert_status); + log_info_printf(pvacms, "Certificate %s:%llu request has been DENIED\n", issuer_id.c_str(), serial); + op->reply(); + } catch (std::exception &e) { + log_err_printf(pvacms, "PVACMS Error denying certificate request: %s\n", e.what()); + op->error(SB() << "Error denying certificate request: " << e.what()); + } +} + +/** + * @brief Get the issuer ID and serial number from the parameters + * + * @param parameters The list of parameters from the SharedWildcardPV + * @return A tuple containing the issuer ID and serial number + */ +std::tuple getParameters(const std::list ¶meters) { + // get serial and issuer from URI parameters + auto it = parameters.begin(); + const std::string &issuer_id = *it; + + const std::string &serial_string = *++it; + uint64_t serial; + try { + serial = std::stoull(serial_string); + } catch (const std::invalid_argument &e) { + throw std::runtime_error(SB() << "Conversion error: Invalid argument. Serial in PV name is not a number: " << serial_string); + } catch (const std::out_of_range &e) { + throw std::runtime_error(SB() << "Conversion error: Out of range. Serial is too large: " << serial_string); + } + + return std::make_tuple(issuer_id, serial); +} + +/** + * @brief Get or create a CA certificate. + * + * Check to see if a CA key and certificate are located where the configuration + * references them and check if they are valid. + * + * If not then create a new key and/or certificate and store them at the configured locations. + * + * If the certificate is invalid then make a backup, notify the user, then + * create a new one. A PVACMS only creates certificates with validity that + * is within the lifetime of the CA certificate so if the CA cert has expired, + * all certificates it has signed will also have expired, and will need to be + * replaced. + * + * @param config the config to use to get CA creation parameters if needed + * @param ca_db the certificate database to write the CA to if needed + * @param ca_cert the reference to the returned certificate + * @param ca_pkey the reference to the private key of the returned certificate + * @param ca_chain reference to the certificate chain of the returned cert + */ +void getOrCreateCaCertificate(ConfigCms &config, sql_ptr &ca_db, ossl_ptr &ca_cert, ossl_ptr &ca_pkey, + ossl_shared_ptr &ca_chain) { + // Get key pair if specified + std::shared_ptr key_pair; + try { + if (!config.ca_private_key_filename.empty()) { + // Check if the CA key exists + key_pair = IdFileFactory::create(config.ca_private_key_filename, config.ca_private_key_password)->getKeyFromFile(); + } + } catch (std::exception &e) { + // Error getting key pair + // Make a new key pair file + try { + log_warn_printf(pvafms, "%s\n", e.what()); + key_pair = createCaKey(config); + } catch (std::exception &e) { + throw(std::runtime_error(SB() << "Error creating CA key: " << e.what())); + } + } + + // At this point if a separate key was configured then we will have one, or we will have thrown an exception + // If we don't have one then it's because it was configured to be in the same file as the certificate + + // Get certificate + try { + // Check if the CA certificates exist + auto cert_data = IdFileFactory::create(config.ca_cert_filename, config.ca_cert_password)->getCertDataFromFile(); + if (!key_pair) key_pair = cert_data.key_pair; + + // If we have a key + if (key_pair) { + // And we have a cert + if (cert_data.cert) { + // all is ok + ca_pkey = std::move(key_pair->pkey); + ca_cert = std::move(cert_data.cert); + ca_chain = cert_data.ca; + return; + } + // We have keys but no cert then create the cert file + throw(std::runtime_error("Certificate file does not contain a certificate: ")); + } + // We don't have keys so create a key in a combined cert and key file + key_pair = IdFileFactory::createKeyPair(); + throw(std::runtime_error("Certificate file does not contain a certificate: ")); + } catch (std::exception &e) { + // Error getting certs file, or certs file invalid + // Make a new CA Certificate + try { + log_warn_printf(pvafms, "%s\n", e.what()); + if (!key_pair) key_pair = IdFileFactory::createKeyPair(); + + auto cert_data = createCaCertificate(config, ca_db, key_pair); + // all is ok + ca_pkey = std::move(key_pair->pkey); + ca_cert = std::move(cert_data.cert); + ca_chain = cert_data.ca; + + // If we had to make a new certificate then we need to make a new ACF and admin client cert + try { + createDefaultAdminACF(config, ca_cert); + } catch (std::exception &e) { + log_err_printf(pvacms, "Error creating ACF file: %s\n", e.what()); + } + + try { + createDefaultAdminClientCert(config, ca_db, ca_pkey, ca_cert, ca_chain); + } catch (std::exception &e) { + log_err_printf(pvacms, "Error creating admin client keychain: %s\n", e.what()); + } + + } catch (std::exception &e) { + throw(std::runtime_error(SB() << "Error creating CA certificate: " << e.what())); + } + } +} + +/** + * @brief Create the default admin ACF file + * + * @param config the config to use to get the ACF filename + * @param ca_cert the CA certificate to use to get the issuer ID and common name + */ +void createDefaultAdminACF(ConfigCms &config, ossl_ptr &ca_cert) { + auto cn = CertStatus::getCommonName(ca_cert); + + // Write the final string to the specified file + std::ofstream out_file(config.ca_acf_filename, std::ios::out | std::ios::trunc); + if (!out_file) { + throw std::runtime_error("Failed to open ACF file for writing: " + config.ca_acf_filename); + } + + out_file << + "UAG(CMS_ADMIN) {admin}\n" + "\n" + "ASG(DEFAULT) {\n" + " RULE(0,READ)\n" + " RULE(1,WRITE) {\n" + " UAG(CMS_ADMIN)\n" + " METHOD(\"x509\")\n" + " AUTHORITY(\"" << cn << "\")\n" + " }\n" + "}"; + + out_file.close(); + log_info_printf(pvacms, "Created Default ACF file: %s\n", config.ca_acf_filename.c_str()); + log_info_printf(pvacms, "--------------------------------------%s", "\n"); + log_info_printf(pvacms, "UAG(CMS_ADMIN) {admin}%s", "\n"); + log_info_printf(pvacms, "%s", "\n"); + log_info_printf(pvacms, "ASG(DEFAULT) {%s", "\n"); + log_info_printf(pvacms, " RULE(0,READ)%s", "\n"); + log_info_printf(pvacms, " RULE(1,WRITE) {%s", "\n"); + log_info_printf(pvacms, " UAG(CMS_ADMIN)%s", "\n"); + log_info_printf(pvacms, " METHOD(\"x509\")%s", "\n"); + log_info_printf(pvacms, " AUTHORITY(\"%s\")%s", cn.c_str(), "\n"); + log_info_printf(pvacms, " }%s", "\n"); + log_info_printf(pvacms, "}%s", "\n"); +} + +void createDefaultAdminClientCert(ConfigCms &config, sql_ptr &ca_db, ossl_ptr &ca_pkey, ossl_ptr &ca_cert, + ossl_shared_ptr &ca_chain) { + auto key_pair = IdFileFactory::createKeyPair(); + auto serial = generateSerial(); + + // Get other certificate parameters from request + auto country = getCountryCode(); + auto name = "admin"; + auto organization = ""; + auto organization_unit = ""; + time_t not_before(time(nullptr)); + time_t not_after(not_before + (4 * 365 + 1) * 24 * 60 * 60); // 4yrs + + // Create a certificate factory + auto certificate_factory = CertFactory(serial, key_pair, name, country, organization, organization_unit, not_before, not_after, ssl::kForClient, true, + ca_cert.get(), ca_pkey.get(), ca_chain.get(), VALID); + + // Create the certificate using the certificate factory, store it in the database and return the PEM string + auto pem_string = createCertificatePemString(ca_db, certificate_factory); + + // If there is a separate key file then write that first + if (!config.admin_private_key_filename.empty()) { + auto cert_file_factory = IdFileFactory::create(config.admin_private_key_filename, config.admin_private_key_password, key_pair); + cert_file_factory->writeIdentityFile(); + log_warn_printf(pvacms, "Created private key file for default PVACMS admin user: %s\n", config.admin_private_key_filename.c_str()); + } + + auto cert_file_factory = IdFileFactory::create(config.admin_cert_filename, config.admin_cert_password, key_pair, nullptr, nullptr, "certificate", + pem_string, !config.admin_private_key_filename.empty()); + cert_file_factory->writeIdentityFile(); + + std::string from = std::ctime(&certificate_factory.not_before_); + std::string to = std::ctime(&certificate_factory.not_after_); + log_info_printf(pvacms, "Created Keychain file for default PVACMS admin user: %s\n", config.admin_cert_filename.c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "NAME: " << certificate_factory.name_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "ORGANIZATION: " << certificate_factory.org_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "ORGANIZATIONAL UNIT: " << certificate_factory.org_unit_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "COUNTRY: " << certificate_factory.country_).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "STATUS: " << CERT_STATE(VALID)).str().c_str()); + log_info_printf(pvacms, "%s\n", (SB() << "VALIDITY: " << from.substr(0, from.size() - 1) << " to " << to.substr(0, to.size() - 1)).str().c_str()); + log_info_printf(pvacms, "--------------------------------------%s", "\n"); +} + +/** + * @brief Ensure that the PVACMS server has a valid certificate. + * + * This will check whether the configured certificate exists, can be opened, + * whether a p12 object can be read from it, and whether the p12 object + * can be parsed to extract the private key, certificate and certificate chain. + * Whether we can extract the root certificate from the certificate + * chain and finally whether we can verify the integrity of the certificate + * + * If any of these checks fail this function will create a new certificate + * at the location referenced in the config, using the configured values + * as parameters. + * + * @param config the config to determine the location of the certificate + * @param ca_db the database to store a new certificate if necessary + * @param ca_cert the CA certificate to use as the issuer of this certificate + * if necessary + * @param ca_pkey the CA certificate's private key used to sign the new + * certificate if necessary + */ +void ensureServerCertificateExists(ConfigCms config, sql_ptr &ca_db, ossl_ptr &ca_cert, ossl_ptr &ca_pkey, + const ossl_shared_ptr &ca_chain) { + // Get key pair if specified + std::shared_ptr key_pair; + try { + if (!config.tls_private_key_filename.empty()) { + // Check if the server key pair exists + key_pair = IdFileFactory::create(config.tls_private_key_filename, config.tls_private_key_password)->getKeyFromFile(); + } + } catch (std::exception &e) { + // Error getting key pair + // Make a new key pair file + try { + log_warn_printf(pvacms, "%s\n", e.what()); + key_pair = createServerKey(config); + } catch (std::exception &e) { + throw(std::runtime_error(SB() << "Error creating server key: " << e.what())); + } + } + + // At this point if a separate key was configured then we will have one, or we will have thrown an exception + // If we don't have one then it's because it was configured to be in the same file as the certificate + + // Get certificate + try { + // Check if the server certificates exist + auto cert_data = IdFileFactory::create(config.tls_cert_filename, config.tls_cert_password)->getCertDataFromFile(); + if (!key_pair) key_pair = cert_data.key_pair; + + // If we have a key + if (key_pair) { + // And we have a cert + if (cert_data.cert) { + // all is ok + return; + } + // We don't have keys so create a key in a combined cert and key file + throw(std::runtime_error("Certificate file does not contain a certificate")); + } + throw(std::runtime_error("Certificate file does not contain a private key")); + } catch (std::exception &e) { + // Error getting certs file, or certs file invalid + // Make a new server Certificate + try { + log_warn_printf(pvacms, "%s\n", e.what()); + if (!key_pair) key_pair = IdFileFactory::createKeyPair(); + + createServerCertificate(config, ca_db, ca_cert, ca_pkey, ca_chain, key_pair); + // All is ok + } catch (std::exception &e) { + throw(std::runtime_error(SB() << "Error creating server certificate: " << e.what())); + } + } +} + +/** + * @brief Create a CA key + * + * @param config the configuration to use to get the parameters to create cert + * @return the key pair + */ +std::shared_ptr createCaKey(ConfigCms &config) { + // Create a key pair + const auto key_pair = IdFileFactory::createKeyPair(); + + // Create key file containing private key + IdFileFactory::create(config.ca_private_key_filename, config.ca_private_key_password, key_pair)->writeIdentityFile(); + return key_pair; +} + +/** + * @brief Create a CA certificate + * + * This function creates a CA certificate based on the configured parameters + * and stores it in the given database as well as writing it out to the + * configured P12 file protected by the optionally specified password. + * + * @param config the configuration to use to get CA creation parameters + * @param ca_db the reference to the certificate database to write the CA to + * @param key_pair the key pair to use for the certificate + * @return a cert data structure containing the cert and chain and a copy of the key + */ +CertData createCaCertificate(ConfigCms &config, sql_ptr &ca_db, std::shared_ptr &key_pair) { + // Set validity to 4 yrs + time_t not_before(time(nullptr)); + time_t not_after(not_before + (4 * 365 + 1) * 24 * 60 * 60); // 4yrs + + // Generate a new serial number + auto serial = generateSerial(); + + auto certificate_factory = CertFactory(serial, key_pair, config.ca_name, config.ca_country, config.ca_organization, config.ca_organizational_unit, + not_before, not_after, ssl::kForCa, config.cert_status_subscription); + + auto pem_string = createCertificatePemString(ca_db, certificate_factory); + + // Create keychain file containing certs, private key and chain + auto cert_file_factory = IdFileFactory::create(config.ca_cert_filename, config.ca_cert_password, key_pair, nullptr, nullptr, "certificate", pem_string, + !config.ca_private_key_filename.empty()); + + cert_file_factory->writeIdentityFile(); + + // Create the root certificate (overwrite existing) + // The user must re-trust it if it already trusted + if (!cert_file_factory->writeRootPemFile(pem_string, true)) { + exit(0); + } + return cert_file_factory->getCertData(key_pair); +} + +/** + * @brief Create a PVACMS server key + * @param config the configuration use to get the parameters to create cert + */ +std::shared_ptr createServerKey(const ConfigCms &config) { + // Create a key pair + const auto key_pair(IdFileFactory::createKeyPair()); + + // Create private key file containing private key + IdFileFactory::create(config.tls_private_key_filename, config.tls_private_key_password, key_pair)->writeIdentityFile(); + return key_pair; +} + +/** + * @brief Create a PVACMS server certificate + * + * If private key file is configured then don't add key to cert file + * + * @param config the configuration use to get the parameters to create cert + * @param ca_db the db to store the certificate in + * @param ca_pkey the CA's private key to sign the certificate + * @param ca_cert the CA certificate + * @param ca_chain the CA certificate chain + * @param key_pair the key pair to use to create the certificate + */ +void createServerCertificate(const ConfigCms &config, sql_ptr &ca_db, ossl_ptr &ca_cert, ossl_ptr &ca_pkey, + const ossl_shared_ptr &ca_chain, std::shared_ptr &key_pair) { + // Generate a new serial number + auto serial = generateSerial(); + + auto certificate_factory = CertFactory(serial, key_pair, config.pvacms_name, config.pvacms_country, config.pvacms_organization, + config.pvacms_organizational_unit, getNotBeforeTimeFromCert(ca_cert.get()), getNotAfterTimeFromCert(ca_cert.get()), + ssl::kForCMS, config.cert_status_subscription, ca_cert.get(), ca_pkey.get(), ca_chain.get()); + + auto cert = createCertificate(ca_db, certificate_factory); + + // Create keychain file containing certs, private key and null chain + auto pem_string = CertFactory::certAndCasToPemString(cert, certificate_factory.certificate_chain_.get()); + auto cert_file_factory = IdFileFactory::create(config.tls_cert_filename, config.tls_cert_password, key_pair, nullptr, nullptr, "PVACMS server certificate", + pem_string, !config.tls_private_key_filename.empty()); + + cert_file_factory->writeIdentityFile(); +} + +/** + * @brief Ensure that start and end dates are within the validity of issuer cert + * + * @param cert_factory the cert factory to check + */ +void ensureValidityCompatible(CertFactory &cert_factory) { + time_t ca_not_before = getNotBeforeTimeFromCert(cert_factory.issuer_certificate_ptr_); + time_t ca_not_after = getNotAfterTimeFromCert(cert_factory.issuer_certificate_ptr_); + + if (cert_factory.not_before_ < ca_not_before) { + throw std::runtime_error("Not before time is before CA's not before time"); + } + if (cert_factory.not_after_ > ca_not_after) { + throw std::runtime_error("Not after time is after CA's not after time"); + } +} + +/** + * @brief Get the current country code of where the process is running + * This returns the two letter country code. It is always upper case. + * For example for the United States it returns US, and for France, FR. + * + * @return the current country code of where the process is running + */ +std::string extractCountryCode(const std::string &locale_str) { + // Look for underscore + auto pos = locale_str.find('_'); + if (pos == std::string::npos || pos + 3 > locale_str.size()) { + return ""; + } + + std::string country_code = locale_str.substr(pos + 1, 2); + std::transform(country_code.begin(), country_code.end(), country_code.begin(), ::toupper); + return country_code; +} + +std::string getCountryCode() { + // 1. Try from std::locale("") + { + std::locale loc(""); + std::string name = loc.name(); + if (name != "C" && name != "POSIX") { + std::string cc = extractCountryCode(name); + if (!cc.empty()) { + return cc; + } + } + } + + // 2. If we failed, try the LANG environment variable + { + const char *lang = std::getenv("LANG"); + if (lang && *lang) { + std::string locale_str(lang); + std::string cc = extractCountryCode(locale_str); + if (!cc.empty()) { + return cc; + } + } + } + + // 3. Default to "US" if both attempts failed + return "US"; +} + +/** + * @brief Get the not after time from the given certificate + * @param cert the certificate to look at for the not after time + * + * @return the time_t representation of the not after time in the certificate + */ +time_t getNotAfterTimeFromCert(const X509 *cert) { + ASN1_TIME *cert_not_after = X509_get_notAfter(cert); + time_t not_after = StatusDate::asn1TimeToTimeT(cert_not_after); + return not_after; +} + +/** + * @brief Get the not before time from the given certificate + * @param cert the certificate to look at for the not before time + * + * @return the time_t representation of the not before time in the certificate + */ +time_t getNotBeforeTimeFromCert(const X509 *cert) { + ASN1_TIME *cert_not_before = X509_get_notBefore(cert); + time_t not_before = StatusDate::asn1TimeToTimeT(cert_not_before); + return not_before; +} + +/** + * @brief Get the IP address of the current process' host. + * + * This will return the IP address based on the following rules. It will + * look through all the network interfaces and will skip local and self + * assigned addresses. Then it will select any public IP address. + * if no public IP addresses are found then it will return + * the first private IP address that it finds + * + * @return the IP address of the current process' host + */ +std::string getIPAddress() { + struct ifaddrs *if_addr_struct = nullptr; + struct ifaddrs *ifa; + void *tmp_addr_ptr; + std::string chosen_ip; + std::string private_ip; + + getifaddrs(&if_addr_struct); + + std::regex local_address_pattern(R"(^(127\.)|(169\.254\.))"); + std::regex private_address_pattern(R"(^(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(192\.168\.))"); + + for (ifa = if_addr_struct; ifa != nullptr; ifa = ifa->ifa_next) { + if (!ifa->ifa_addr) { + continue; + } + if (ifa->ifa_addr->sa_family == AF_INET) { + // is a valid IPv4 Address + tmp_addr_ptr = &((struct sockaddr_in *)ifa->ifa_addr)->sin_addr; + char address_buffer[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, tmp_addr_ptr, address_buffer, INET_ADDRSTRLEN); + + // Skip local or self-assigned address. If it's a private address, + // remember it. + if (!std::regex_search(address_buffer, local_address_pattern)) { + if (std::regex_search(address_buffer, private_address_pattern)) { + if (private_ip.empty()) { + private_ip = address_buffer; + } + } else { + chosen_ip = address_buffer; + break; // If a public address is found, exit the loop + } + } + } + } + if (if_addr_struct != nullptr) freeifaddrs(if_addr_struct); + + // If no public IP addresses were found, use the first private IP that was + // found. + if (chosen_ip.empty()) { + chosen_ip = private_ip; + } + + return chosen_ip; +} + +template +void setValue(Value &target, const std::string &field, const T &source) { + auto current = target[field]; + if (current.as() == source) { + target[field].unmark(); // Assuming unmark is a valid method for indicating no change needed + } else { + target[field] = source; + } +} + +/** + * @brief Posts the status of a certificate to the shared wildcard PV. + * + * This function posts the status of a certificate to a shared wildcard PV so that any listeners will be notified. + * The shared wildcard PV is a data structure that can be accessed by multiple clients through a server. + * The status of the certificate is represented by the CertStatus enum. + * + * @param status_pv The shared wildcard PV to post the status to. + * @param pv_name The pv_name of the status to post. + * @param serial The serial number of the certificate. + * @param cert_status The status of the certificate (UNKNOWN, VALID, EXPIRED, REVOKED, PENDING_APPROVAL, PENDING). + * @param open_only Specifies whether to close the shared wildcard PV again after setting the status if it was closed to begin with. + */ +epicsMutex status_pv_lock; +Value postCertificateStatus(server::SharedWildcardPV &status_pv, const std::string &pv_name, uint64_t serial, const PVACertificateStatus &cert_status) { + Guard G(status_pv_lock); + Value status_value; + auto was_open = status_pv.isOpen(pv_name); + if (was_open) { + status_value = status_pv.fetch(pv_name); + } else { + status_value = CertStatus::getStatusPrototype(); + } + setValue(status_value, "serial", serial); + setValue(status_value, "status.value.index", cert_status.status.i); + setValue(status_value, "status.timeStamp.secondsPastEpoch", time(nullptr)); + setValue(status_value, "state", cert_status.status.s); + // Set OCSP state to default values even if bytes are not set + setValue(status_value, "ocsp_status.value.index", cert_status.ocsp_status.i); + setValue(status_value, "ocsp_status.timeStamp.secondsPastEpoch", time(nullptr)); + setValue(status_value, "ocsp_state", SB() << "**UNCERTIFIED**: " << cert_status.ocsp_status.s); + + // Get ocsp info if specified + if (!cert_status.ocsp_bytes.empty()) { + setValue(status_value, "ocsp_status.value.index", cert_status.ocsp_status.i); + setValue(status_value, "ocsp_state", cert_status.ocsp_status.s); + setValue(status_value, "ocsp_status_date", cert_status.status_date.s); + setValue(status_value, "ocsp_certified_until", cert_status.status_valid_until_date.s); + setValue(status_value, "ocsp_revocation_date", cert_status.revocation_date.s); + auto ocsp_bytes = shared_array(cert_status.ocsp_bytes.begin(), cert_status.ocsp_bytes.end()); + status_value["ocsp_response"] = ocsp_bytes.freeze(); + } + + log_debug_printf(pvacms, "Posting Certificate Status: %s = %s\n", pv_name.c_str(), cert_status.status.s.c_str()); + if (was_open) { + status_pv.post(pv_name, status_value); + } else { + status_pv.open(pv_name, status_value); + } + return status_value; +} + +/** + * @brief Posts the error status of a certificate to the shared wildcard PV. + * + * This function posts the error status of a certificate to a shared wildcard PV so that any listeners will be notified. + * The shared wildcard PV is a data structure that can be accessed by multiple clients through a server. + * The error status of the certificate error_status, error_severity and error_message parameters. + * + * @param status_pv The shared wildcard PV to post the error status to. + * @param issuer_id The issuer ID of the certificate. + * @param serial The serial number of the certificate. + * @param error_status error status. + * @param error_severity error severity + * @param error_message The error message + */ +void postCertificateErrorStatus(server::SharedWildcardPV &status_pv, std::unique_ptr &&op, const std::string &our_issuer_id, + const uint64_t &serial, const int32_t error_status, const int32_t error_severity, const std::string &error_message) { + Guard G(status_pv_lock); + std::string pv_name = getCertUri(GET_MONITOR_CERT_STATUS_ROOT, our_issuer_id, serial); + Value status_value{CertStatus::getStatusPrototype()}; + auto cert_status = PVACertificateStatus(); // Create an UNKNOWN CertificateStatus + setValue(status_value, "serial", serial); + setValue(status_value, "status.value.index", cert_status.status.i); + setValue(status_value, "status.timeStamp.secondsPastEpoch", time(nullptr)); + setValue(status_value, "state", cert_status.status.s); + + status_value["status.alarm.status"] = error_status; + status_value["status.alarm.severity"] = error_severity; + status_value["status.alarm.message"] = error_message; + + status_value["status.value.index"] = UNKNOWN; + status_value["serial"] = serial; + log_debug_printf(pvacms, "Posting Certificate Error Status: %s = %s\n", pv_name.c_str(), error_message.c_str()); + if (status_pv.isOpen(pv_name)) + status_pv.post(pv_name, status_value); + else { + status_pv.open(pv_name, status_value); + } + if (op != nullptr) op->error(error_message); +} + +/** + * @brief This function returns the certificate URI. + * + * The certificate URI is generated by concatenating the provided `prefix` with the certificate ID obtained + * from the `issuer_id` and `serial`. The certificate ID is generated using the `getCertId` function. + * + * @param prefix The prefix used to construct the certificate URI. + * @param issuer_id The issuer ID used to generate the certificate ID. + * @param serial The serial number used to generate the certificate ID. + * @return The certificate URI string. + */ +std::string getCertUri(const std::string &prefix, const std::string &issuer_id, const uint64_t &serial) { + return getCertUri(prefix, getCertId(issuer_id, serial)); +} + +/** + * @brief Returns the certificate URI. + * + * This function takes a prefix and a certificate ID as input parameters and returns the certificate URI. + * The certificate URI is constructed by concatenating the prefix and the certificate ID using a colon (:) as a separator. + * + * @param prefix The prefix string for the certificate URI. + * @param cert_id The certificate ID string. + * @return The certificate URI string. + */ +std::string getCertUri(const std::string &prefix, const std::string &cert_id) { + const std::string pv_name(SB() << prefix << ":" << cert_id); + return pv_name; +} + +/** + * @brief Generates a unique certificate ID based on the issuer ID and serial number. + * + * This function takes the issuer ID and serial number as input and combines them + * into a unique certificate ID. The certificate ID is generated by concatenating + * the issuer ID and serial number with a ":" separator. + * + * @param issuer_id The issuer ID of the certificate. + * @param serial The serial number of the certificate. + * @return The unique certificate ID. + * + * @see SB + */ +std::string getCertId(const std::string &issuer_id, const uint64_t &serial) { + const std::string cert_id(SB() << issuer_id << ":" << serial); + return cert_id; +} + +bool statusMonitor(StatusMonitor &status_monitor_params) { + log_debug_printf(pvacmsmonitor, "Certificate Monitor Thread Wake Up%s", "\n"); + auto cert_status_creator(CertStatusFactory(status_monitor_params.ca_cert_, status_monitor_params.ca_pkey_, status_monitor_params.ca_chain_, + status_monitor_params.config_.cert_status_validity_mins)); + sqlite3_stmt *stmt; + + // Search for any certs that have become valid + std::string valid_sql(SQL_CERT_TO_VALID); + const std::vector valid_status{PENDING}; + valid_sql += getValidStatusesClause(valid_status); + if (sqlite3_prepare_v2(status_monitor_params.ca_db_.get(), valid_sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) { + bindValidStatusClauses(stmt, valid_status); + + // Do one then reschedule the rest + if (sqlite3_step(stmt) == SQLITE_ROW) { + int64_t db_serial = sqlite3_column_int64(stmt, 0); + uint64_t serial = *reinterpret_cast(&db_serial); + try { + const std::string pv_name(getCertUri(GET_MONITOR_CERT_STATUS_ROOT, status_monitor_params.issuer_id_, serial)); + updateCertificateStatus(status_monitor_params.ca_db_, serial, VALID, 1, {PENDING}); + auto status_date = std::time(nullptr); + auto cert_status = cert_status_creator.createPVACertificateStatus(serial, VALID, status_date); + postCertificateStatus(status_monitor_params.status_pv_, pv_name, serial, cert_status); + log_info_printf(pvacmsmonitor, "Certificate %s:%llu has become VALID\n", status_monitor_params.issuer_id_.c_str(), serial); + } catch (const std::runtime_error &e) { + log_err_printf(pvacmsmonitor, "PVACMS Certificate Monitor Error: %s\n", e.what()); + } + } + sqlite3_finalize(stmt); + } else { + log_err_printf(pvacmsmonitor, "PVACMS Certificate Monitor Error: %s\n", sqlite3_errmsg(status_monitor_params.ca_db_.get())); + } + + // Search for any certs that have expired + std::string expired_sql(SQL_CERT_TO_EXPIRED); + const std::vector expired_status{VALID, PENDING_APPROVAL, PENDING}; + expired_sql += getValidStatusesClause(expired_status); + if (sqlite3_prepare_v2(status_monitor_params.ca_db_.get(), expired_sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) { + bindValidStatusClauses(stmt, expired_status); + + if (sqlite3_step(stmt) == SQLITE_ROW) { + int64_t db_serial = sqlite3_column_int64(stmt, 0); + uint64_t serial = *reinterpret_cast(&db_serial); + try { + const std::string pv_name(getCertUri(GET_MONITOR_CERT_STATUS_ROOT, status_monitor_params.issuer_id_, serial)); + updateCertificateStatus(status_monitor_params.ca_db_, serial, EXPIRED, -1, {VALID, PENDING_APPROVAL, PENDING}); + auto status_date = std::time(nullptr); + auto cert_status = cert_status_creator.createPVACertificateStatus(serial, EXPIRED, status_date); + postCertificateStatus(status_monitor_params.status_pv_, pv_name, serial, cert_status); + log_info_printf(pvacmsmonitor, "Certificate %s:%llu has EXPIRED\n", status_monitor_params.issuer_id_.c_str(), serial); + } catch (const std::runtime_error &e) { + log_err_printf(pvacmsmonitor, "PVACMS Certificate Monitor Error: %s\n", e.what()); + } + } + sqlite3_finalize(stmt); + } else { + log_err_printf(pvacmsmonitor, "PVACMS Certificate Monitor Error: %s\n", sqlite3_errmsg(status_monitor_params.ca_db_.get())); + } + + log_debug_printf(pvacmsmonitor, "Certificate Monitor Thread Sleep%s", "\n"); + return true; // We're not done - check files too +} + +} // namespace certs +} // namespace pvxs + +int main(int argc, char *argv[]) { + using namespace pvxs::certs; + using namespace pvxs::server; + + try { + // Get config + auto config = ConfigCms::fromEnv(); + pvxs::sql_ptr ca_db; + + CLI::App app{"PVACMS - Certificate Management Service"}; + + // Variables for each option + bool verbose = false; + bool show_version = false; + + std::string ca_password_file, pvacms_password_file, admin_password_file; + std::string ca_pk_password_file, pvacms_pk_password_file, admin_pk_password_file; + + // Define options + app.set_help_flag("-h,--help", "Show this message"); + app.add_flag("-v,--verbose", verbose, "Make more noise"); + app.add_flag("-V,--version", show_version, "Print version and exit."); + + app.add_option("--ck,--ca-keychain", config.ca_cert_filename, "Specify CA keychain file location")->default_val(config.ca_cert_filename); + app.add_option("--cpk,--ca-private-key", config.ca_private_key_filename, "Specify CA private key file location"); + app.add_option("--ckp,--ca-keychain-pwd", ca_password_file, "Specify CA keychain password file location"); + app.add_option("--cpkp,--ca-private-key-pwd", ca_pk_password_file, "Specify CA private key password file location"); + + app.add_option("--pk,--pvacms-keychain", config.tls_cert_filename, "Specify PVACMS keychain file location")->default_val(config.tls_cert_filename); + app.add_option("--ppk,--pvacms-private-key", config.tls_private_key_filename, "Specify PVACMS private key file location"); + app.add_option("--pkp,--pvacms-keychain-pwd", pvacms_password_file, "Specify PVACMS keychain password file location"); + app.add_option("--ppkp,--pvacms-private-key-pwd", pvacms_pk_password_file, "Specify PVACMS private key password file location"); + + app.add_option("--ak,--admin-keychain", config.admin_cert_filename, "Specify PVACMS admin user's keychain file location") + ->default_val(config.admin_cert_filename); + app.add_option("--apk,--admin-private-key", config.admin_private_key_filename, "Specify PVACMS admin user's private key file location"); + app.add_option("--akp,--admin-keychain-pwd", admin_password_file, "Specify PVACMS admin user's keychain password file location"); + app.add_option("--apkp,--admin-private-key-pwd", admin_pk_password_file, "Specify PVACMS admin user's private key password file location"); + + app.add_option("--cn,--ca-name", config.ca_name, "Specify the CA's name. Used if we need to create a root certificate")->default_val(config.ca_name); + app.add_option("--co,--ca-org", config.ca_organization, "Specify the CA's Organization. Used if we need to create a root certificate") + ->default_val("ca.epics.org"); + app.add_option("--cou,--ca-org-unit", config.ca_organizational_unit, "Specify the CA's Organization Unit. Used if we need to create a root certificate") + ->default_val("EPICS Certificate Authority"); + app.add_option("--cc,--ca-country", config.ca_country, "Specify the CA's Country. Used if we need to create a root certificate") + ->default_val(config.ca_country.empty() ? getCountryCode() : config.ca_country); + + app.add_option("--pn,--pvacms-name", config.pvacms_name, "Specify the PVACMS name. Used if we need to create a PVACMS certificate") + ->default_val("PVACMS"); + app.add_option("--po,--pvacms-org", config.pvacms_organization, "Specify the PVACMS Organization. Used if we need to create a PVACMS certificate") + ->default_val("ca.epics.org"); + app.add_option("--pou,--pvacms-org-unit", config.pvacms_organizational_unit, + "Specify the PVACMS Organization Unit. Used if we need to create a PVACMS certificate") + ->default_val("EPICS Certificate Authority"); + app.add_option("--pc,--pvacms-country", config.pvacms_country, "Specify the PVACMS Country. Used if we need to create a PVACMS certificate") + ->default_val(config.pvacms_country.empty() ? getCountryCode() : config.pvacms_country); + + app.add_option("-s,--acf", config.ca_acf_filename, "Access security Configuration File")->default_val(config.ca_acf_filename); + app.add_option("-d,--cert-db", config.ca_db_filename, "Specify cert db file location")->default_val(config.ca_db_filename); + + app.add_option("--client-require-approval", config.cert_client_require_approval, "Generate Client Certificates in PENDING_APPROVAL state") + ->default_val(config.cert_client_require_approval); + app.add_option("--server-require-approval", config.cert_server_require_approval, "Generate Server Certificates in PENDING_APPROVAL state") + ->default_val(config.cert_server_require_approval); + app.add_option("--gateway-require-approval", config.cert_gateway_require_approval, "Generate Server Certificates in PENDING_APPROVAL state") + ->default_val(config.cert_gateway_require_approval); + + app.add_option("--svm,--status-validity-mins", config.cert_status_validity_mins, "Set Status Validity Time in Minutes") + ->default_val(config.cert_status_validity_mins); + app.add_option("--sme,--status-monitoring-enabled", config.cert_status_subscription, + "Require Peers to monitor Status of Certificates Generated by this server by default. Can be overridden in each CCR") + ->default_val(config.cert_status_subscription); + + CLI11_PARSE(app, argc, argv); + + // Make sure some directories exist + if (!config.ca_cert_filename.empty()) config.ensureDirectoryExists(config.ca_cert_filename); + if (!config.ca_private_key_filename.empty()) config.ensureDirectoryExists(config.ca_private_key_filename); + + if (!config.tls_cert_filename.empty()) config.ensureDirectoryExists(config.tls_cert_filename); + if (!config.tls_private_key_filename.empty()) config.ensureDirectoryExists(config.tls_private_key_filename); + + if (!config.ca_acf_filename.empty()) config.ensureDirectoryExists(config.ca_acf_filename); + + if (!config.admin_cert_filename.empty()) config.ensureDirectoryExists(config.admin_cert_filename); + if (!config.admin_private_key_filename.empty()) config.ensureDirectoryExists(config.admin_private_key_filename); + + if (!config.ca_db_filename.empty()) config.ensureDirectoryExists(config.ca_db_filename); + + if (!ca_password_file.empty()) config.ensureDirectoryExists(ca_password_file); + if (!ca_pk_password_file.empty()) config.ensureDirectoryExists(ca_pk_password_file); + if (!pvacms_password_file.empty()) config.ensureDirectoryExists(pvacms_password_file); + if (!pvacms_pk_password_file.empty()) config.ensureDirectoryExists(pvacms_pk_password_file); + if (!admin_password_file.empty()) config.ensureDirectoryExists(admin_password_file); + if (!admin_pk_password_file.empty()) config.ensureDirectoryExists(admin_pk_password_file); + + // Read in some passwords from files + if (!ca_password_file.empty()) config.ca_cert_password = config.getFileContents(ca_password_file); + if (!ca_pk_password_file.empty()) config.ca_private_key_password = config.getFileContents(ca_pk_password_file); + if (!pvacms_password_file.empty()) config.tls_cert_filename = config.getFileContents(pvacms_password_file); + if (!pvacms_pk_password_file.empty()) config.tls_private_key_password = config.getFileContents(pvacms_pk_password_file); + if (!admin_password_file.empty()) config.admin_cert_password = config.getFileContents(admin_password_file); + if (!admin_pk_password_file.empty()) config.admin_private_key_password = config.getFileContents(admin_pk_password_file); + + // Override some settings for PVACMS + config.tls_stop_if_no_cert = true; + config.tls_client_cert_required = pvxs::impl::ConfigCommon::Optional; + + if (show_version) { + std::cout << pvxs::version_information; + return 0; + } + + // Initialize SSL + pvxs::ossl::SSLContext::sslInit(); + + // Logger config from environment (so environment overrides verbose setting) + if (verbose) logger_level_set("pvxs.certs.*", pvxs::Level::Info); + pvxs::logger_config_env(); + + // Initialize the certificates database + initCertsDatabase(ca_db, config.ca_db_filename); + + // Get the CA Certificate + pvxs::ossl_ptr ca_pkey; + pvxs::ossl_ptr ca_cert; + pvxs::ossl_shared_ptr ca_chain; + + // Get or create CA certificate + getOrCreateCaCertificate(config, ca_db, ca_cert, ca_pkey, ca_chain); + auto our_issuer_id = CertStatus::getIssuerId(ca_cert); + + // Create this PVACMS server's certificate if it does not already exist + ensureServerCertificateExists(config, ca_db, ca_cert, ca_pkey, ca_chain); + + // Set security if configured + if (!config.ca_acf_filename.empty()) { + log_warn_printf(pvacms, "PVACMS secured with %s\n", config.ca_acf_filename.c_str()); + asInitFile(config.ca_acf_filename.c_str(), ""); + } else { + log_err_printf(pvacms, "****EXITING****: PVACMS Access Security Policy File Required%s", "\n"); + return 1; + } + + // Create the PVs + SharedPV create_pv(SharedPV::buildReadonly()); + SharedPV root_pv(SharedPV::buildReadonly()); + SharedWildcardPV status_pv(SharedWildcardPV::buildMailbox()); + + // Create Root PV value which won't change + // TODO what happens when it changes + pvxs::Value root_pv_value = getRootValue(our_issuer_id, ca_cert, ca_chain); + + // RPC handlers + pvxs::ossl_ptr ca_pub_key(X509_get_pubkey(ca_cert.get())); + create_pv.onRPC( + [&config, &ca_db, &ca_pkey, &ca_cert, &ca_pub_key, ca_chain, &our_issuer_id](const SharedPV &pv, std::unique_ptr &&op, pvxs::Value &&args) { + onCreateCertificate(config, ca_db, pv, std::move(op), std::move(args), ca_pkey, ca_cert, ca_pub_key, ca_chain, our_issuer_id); + }); + + // Client Connect handlers GET/MONITOR + status_pv.onFirstConnect([&config, &ca_db, &ca_pkey, &ca_cert, &ca_chain, &our_issuer_id](SharedWildcardPV &pv, const std::string &pv_name, + const std::list ¶meters) { + onGetStatus(config, ca_db, our_issuer_id, pv, pv_name, parameters, ca_pkey, ca_cert, ca_chain); + }); + status_pv.onLastDisconnect([](SharedWildcardPV &pv, const std::string &pv_name, const std::list ¶meters) { pv.close(pv_name); }); + + // PUT handlers + status_pv.onPut([&config, &ca_db, &our_issuer_id, &ca_pkey, &ca_cert, &ca_chain](SharedWildcardPV &pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, + pvxs::Value &&value) { + // Make sure that pv is open before any put operation + if (!pv.isOpen(pv_name)) { + pv.open(pv_name, CertStatus::getStatusPrototype()); + } + + std::string issuer_id; + uint64_t serial; + std::tie(issuer_id, serial) = getParameters(parameters); + + // Get desired state + auto state = value["state"].as(); + std::transform(state.begin(), state.end(), state.begin(), ::toupper); + + // Get credentials for this operation + auto creds = op->credentials(); + + pvxs::ioc::Credentials credentials(*creds); + + // Get security client from channel + pvxs::ioc::SecurityClient securityClient; + + // TODO move somewhere else + // We're using DEFAULT ASG + // ASmember needs to outlive the server (to allow all clients to disconnect) + static ASMember as_member; + securityClient.update(as_member.mem, ASL1, credentials); + + if (!securityClient.canWrite()) { + log_err_printf(pvacms, "PVACMS Client Not Authorised%s", "\n"); + op->error(pvxs::SB() << state << " operation not authorized on " << issuer_id << ":" << serial ); + return; + } + + if (state == "REVOKED") { + onRevoke(config, ca_db, our_issuer_id, pv, std::move(op), pv_name, parameters, ca_pkey, ca_cert, ca_chain); + } else if (state == "APPROVED") { + onApprove(config, ca_db, our_issuer_id, pv, std::move(op), pv_name, parameters, ca_pkey, ca_cert, ca_chain); + } else if (state == "DENIED") { + onDeny(config, ca_db, our_issuer_id, pv, std::move(op), pv_name, parameters, ca_pkey, ca_cert, ca_chain); + } else { + postCertificateErrorStatus(pv, std::move(op), our_issuer_id, serial, 1, 1, pvxs::SB() << "Invalid certificate state requested: " << state); + } + }); + + StatusMonitor status_monitor_params(config, ca_db, our_issuer_id, status_pv, ca_cert, ca_pkey, ca_chain); + + // Create a server with a certificate monitoring function attached to the cert file monitor timer + // Return true to indicate that we want the file monitor time to run after this + Server pva_server = Server(config, [&status_monitor_params](short evt) { return statusMonitor(status_monitor_params); }); + + pva_server.addPV(RPC_CERT_CREATE, create_pv).addPV(GET_MONITOR_CERT_STATUS_PV, status_pv).addPV(kCertRoot, root_pv); + root_pv.open(root_pv_value); + + if (verbose) { + std::cout << "Effective config\n" << config; + } + + try { + log_info_printf(pvacms, "PVACMS Running%s", "\n"); + pva_server.run(); + log_info_printf(pvacms, "PVACMS Exiting%s", "\n"); + } catch (const std::exception &e) { + log_err_printf(pvacms, "PVACMS error: %s\n", e.what()); + } + + return 0; + } catch (std::exception &e) { + log_err_printf(pvacms, "PVACMS Error: %s\n", e.what()); + return 1; + } +} diff --git a/certs/pvacms.h b/certs/pvacms.h new file mode 100644 index 000000000..a81570db3 --- /dev/null +++ b/certs/pvacms.h @@ -0,0 +1,275 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The PVAccess Certificate Management Service. + * + * pvacms.h + * + */ +#ifndef PVXS_PVACMS_H +#define PVXS_PVACMS_H + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "certfactory.h" +#include "certfilefactory.h" +#include "certstatus.h" +#include "configcms.h" +#include "ownedptr.h" + +#define RPC_CERT_CREATE "CERT:CREATE" + +#define DEFAULT_KEYCHAIN_FILE "server.p12" +#define DEFAULT_CA_KEYCHAIN_FILE "ca.p12" +#define DEFAULT_ACF_FILE "pvacms.acf" + +#define RPC_CERT_REVOKE_ROOT "CERT:REVOKE" + +#define CERT_MONITOR_POLLING_INTERVAL_SECS 10 + +#define PVXS_HOSTNAME_MAX 1024 + +#define SQL_CREATE_DB_FILE \ + "BEGIN TRANSACTION;" \ + "CREATE TABLE IF NOT EXISTS certs(" \ + " serial INTEGER," \ + " skid TEXT," \ + " CN TEXT," \ + " O TEXT," \ + " OU TEXT," \ + " C TEXT," \ + " approved INTEGER," \ + " not_before INTEGER," \ + " not_after INTEGER," \ + " status INTEGER," \ + " status_date INTEGER" \ + ");" \ + "COMMIT;" + +#define SQL_CREATE_CERT \ + "INSERT INTO certs ( " \ + " serial," \ + " skid," \ + " CN," \ + " O," \ + " OU," \ + " C," \ + " approved," \ + " not_before," \ + " not_after," \ + " status," \ + " status_date" \ + ") " \ + "VALUES (" \ + " :serial," \ + " :skid," \ + " :CN," \ + " :O," \ + " :OU," \ + " :C," \ + " :approved," \ + " :not_before," \ + " :not_after," \ + " :status," \ + " :status_date" \ + ")" + +#define SQL_DUPS_SUBJECT \ + "SELECT COUNT(*) " \ + "FROM certs " \ + "WHERE CN = :CN " \ + " AND O = :O " \ + " AND OU = :OU " \ + " AND C = :C " + +#define SQL_DUPS_SUBJECT_KEY_IDENTIFIER \ + "SELECT COUNT(*) " \ + "FROM certs " \ + "WHERE skid = :skid " + +#define SQL_CERT_STATUS \ + "SELECT status " \ + " , status_date " \ + "FROM certs " \ + "WHERE serial = :serial" + +#define SQL_CERT_VALIDITY \ + "SELECT not_before " \ + " , not_after " \ + "FROM certs " \ + "WHERE serial = :serial" + +#define SQL_CERT_SET_STATUS \ + "UPDATE certs " \ + "SET status = :status " \ + " , status_date = :status_date " \ + "WHERE serial = :serial " + +#define SQL_CERT_SET_STATUS_W_APPROVAL \ + "UPDATE certs " \ + "SET status = :status " \ + " , approved = :approved " \ + " , status_date = :status_date " \ + "WHERE serial = :serial " + +#define SQL_CERT_TO_VALID \ + "SELECT serial " \ + "FROM certs " \ + "WHERE not_before <= strftime('%s', 'now') " \ + " AND not_after > strftime('%s', 'now') " + +#define SQL_CERT_TO_EXPIRED \ + "SELECT serial " \ + "FROM certs " \ + "WHERE not_after <= strftime('%s', 'now') " + +#define SQL_PRIOR_APPROVAL_STATUS \ + "SELECT approved " \ + "FROM certs " \ + "WHERE CN = :CN " \ + " AND O = :O " \ + " AND OU = :OU " \ + " AND C = :C " \ + "ORDER BY status_date DESC " \ + "LIMIT 1 " + +namespace pvxs { +namespace certs { + +/** + * @brief Monitors the certificate status and updates the shared wildcard status pv when any become valid or expire. + * + * This function monitors the certificate status by connecting to the Certificate database, and searching + * for all certificates that have just expired and all certificates that have just become valid. If any + * are found then the associated shared wildcard PV is updated and the new status stored in the database. + * + * @param ca_db The certificates database object. + * @param issuer_id The issuer ID. + * @param status_pv The shared wildcard PV to notify. + * + * @note This function assumes that the CA database and the status PV have been properly configured and initialized. + * @note The status_pv parameter must be a valid SharedWildcardPV object. + */ +class StatusMonitor { + public: + ConfigCms &config_; + sql_ptr &ca_db_; + std::string &issuer_id_; + server::SharedWildcardPV &status_pv_; + pvxs::ossl_ptr &ca_cert_; + pvxs::ossl_ptr &ca_pkey_; + pvxs::ossl_shared_ptr &ca_chain_; + + public: + StatusMonitor(ConfigCms &config, sql_ptr &ca_db, std::string &issuer_id, server::SharedWildcardPV &status_pv, ossl_ptr &ca_cert, + ossl_ptr &ca_pkey, ossl_shared_ptr &ca_chain) + : config_(config), ca_db_(ca_db), issuer_id_(issuer_id), status_pv_(status_pv), ca_cert_(ca_cert), ca_pkey_(ca_pkey), ca_chain_(ca_chain) {} +}; + +void checkForDuplicates(sql_ptr &ca_db, CertFactory &cert_factory); + +std::shared_ptr createCaKey(ConfigCms &config); +CertData createCaCertificate(ConfigCms &config, sql_ptr &ca_db, std::shared_ptr &key_pair); + +ossl_ptr createCertificate(sql_ptr &ca_db, CertFactory &cert_factory); + +std::string createCertificatePemString(sql_ptr &ca_db, CertFactory &cert_factory); + +std::shared_ptr createServerKey(const ConfigCms &config); +void createServerCertificate(const ConfigCms &config, sql_ptr &ca_db, ossl_ptr &ca_cert, ossl_ptr &ca_pkey, + const ossl_shared_ptr &ca_chain, std::shared_ptr &key_pair); + +void ensureServerCertificateExists(ConfigCms config, sql_ptr &ca_db, ossl_ptr &ca_cert, ossl_ptr &ca_pkey, + const ossl_shared_ptr &ca_chain); + +void ensureValidityCompatible(CertFactory &cert_factory); + +uint64_t generateSerial(); + +std::tuple getCertificateStatus(sql_ptr &ca_db, uint64_t serial); +std::tuple getCertificateValidity(sql_ptr &ca_db, uint64_t serial); + +std::string extractCountryCode(const std::string &locale_str); + +std::string getCountryCode(); + +Value getCreatePrototype(); + +std::string getIPAddress(); + +time_t getNotAfterTimeFromCert(const X509 *cert); + +time_t getNotBeforeTimeFromCert(const X509 *cert); + +void getOrCreateCaCertificate(ConfigCms &config, sql_ptr &ca_db, ossl_ptr &ca_cert, ossl_ptr &ca_pkey, + ossl_shared_ptr &ca_chain); + +void createDefaultAdminACF(ConfigCms &config, ossl_ptr &ca_cert); + +void createDefaultAdminClientCert(ConfigCms &config, sql_ptr &ca_db, ossl_ptr &ca_pkey, ossl_ptr &ca_cert, + ossl_shared_ptr &ca_chain); + +void initCertsDatabase(sql_ptr &ca_db, std::string &db_file); + +void onCreateCertificate(ConfigCms &config, sql_ptr &ca_db, const server::SharedPV &pv, std::unique_ptr &&op, Value &&args, + const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, const ossl_ptr &ca_pub_key, + const ossl_shared_ptr &ca_chain, std::string issuer_id); + +bool getPriorApprovalStatus(sql_ptr &ca_db, std::string &name, std::string &country, std::string &organization, std::string &organization_unit); + +void onGetStatus(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, const std::string &pv_name, + const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain); + +void onRevoke(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain); + +void onApprove(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain); + +void onDeny(ConfigCms &config, sql_ptr &ca_db, const std::string &our_issuer_id, server::SharedWildcardPV &status_pv, std::unique_ptr &&op, + const std::string &pv_name, const std::list ¶meters, const ossl_ptr &ca_pkey, const ossl_ptr &ca_cert, + const ossl_shared_ptr &ca_chain); + +int readOptions(ConfigCms &config, int argc, char *argv[], bool &verbose); + +void updateCertificateStatus(sql_ptr &ca_db, uint64_t serial, certstatus_t cert_status, int approval_status, + std::vector valid_status = {PENDING_APPROVAL, PENDING, VALID}); + +certstatus_t storeCertificate(sql_ptr &ca_db, CertFactory &cert_factory); + +bool statusMonitor(StatusMonitor &status_monitor_params); + +Value postCertificateStatus(server::SharedWildcardPV &status_pv, const std::string &pv_name, uint64_t serial, const PVACertificateStatus &cert_status = {}); +void postCertificateErrorStatus(server::SharedWildcardPV &status_pv, std::unique_ptr &&op, const std::string &our_issuer_id, + const uint64_t &serial, int32_t error_status, int32_t error_severity, const std::string &error_message); + +std::string getCertUri(const std::string &prefix, const std::string &issuer_id, const uint64_t &serial); +std::string getCertUri(const std::string &prefix, const std::string &cert_id); +std::string getCertId(const std::string &issuer_id, const uint64_t &serial); +std::string getValidStatusesClause(std::vector valid_status); +void bindValidStatusClauses(sqlite3_stmt *sql_statement, std::vector valid_status); +std::tuple getParameters(const std::list ¶meters); + +template +void setValue(Value &target, const std::string &field, const T &source); + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_PVACMS_H diff --git a/certs/security.h b/certs/security.h new file mode 100644 index 000000000..0d61e4162 --- /dev/null +++ b/certs/security.h @@ -0,0 +1,135 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_SEC_SECURITY_H +#define PVXS_SEC_SECURITY_H + +#include + +#include "ownedptr.h" + +namespace pvxs { +namespace certs { + +/** + * @class Credentials + * @brief Represents the credentials for an abstract authentication type. + * + * This structure provides the principal name. + */ +struct Credentials { + virtual ~Credentials() {}; + + // Principal's name - e.g. username, or device name, or IP address. + std::string name; + std::string country; + std::string organization; + std::string organization_unit; + + // Validity + time_t not_before; + time_t not_after; +}; + +#define CCR_PROTOTYPE(VERIFIER) \ + { \ + members::String("type"), \ + members::String("name"), \ + members::String("country"), \ + members::String("organization"), \ + members::String("organization_unit"), \ + members::UInt16("usage"), \ + members::UInt64("not_before"), \ + members::UInt64("not_after"), \ + members::String("pub_key"), \ + members::Struct("verifier", VERIFIER), \ + } + +struct CertCreationRequest final { + std::shared_ptr credentials; + + // Type of authenticator to use to verify this certificate creation request: + // "x509", "krb", etc + std::string type; + + // PVStructure containing the authentication type specific CSR to be + // transmitted over the wire. The type field is used to in the server side + // switch to correctly decode and verify the ccr. + // + // The verification structure will be filled by the authenticator subtypes + // based on their verification needs. If your authentication method can use + // just a string token to pass verification information then use this + // definition directly, otherwise replace the definition with a similar + // structure definition except that the verifier substructure will be your + // custom verification structure. The server will recognise the type and + // understand how to decode it. + // + // Note: Only the claims in the certificate are significant when it comes to + // verification. The other fields are included for information, fail fast + // optimisations, and debugging. + // + Value ccr; + std::vector verifier_fields; + + // Constructor + CertCreationRequest(const std::string &auth_type, std::vector verifier_fields) : type(auth_type), verifier_fields(verifier_fields) { + ccr = TypeDef(TypeCode::Struct, CCR_PROTOTYPE(verifier_fields)).create(); + } +}; + +struct KeyPair final { + std::string public_key; + ossl_ptr pkey; + + // Default constructor + KeyPair() = default; + + explicit KeyPair(ossl_ptr new_pkey) : pkey(std::move(new_pkey)) { + ossl_ptr bio(BIO_new(BIO_s_mem())); + + if (!PEM_write_bio_PUBKEY(bio.get(), pkey.get())) { + throw std::runtime_error("Failed to write public key to BIO"); + } + + BUF_MEM *bptr; // to hold pointer to data in the BIO object. + BIO_get_mem_ptr(bio.get(), &bptr); // set to point into BIO object + + // Create a string from the BIO + std::string result(bptr->data, bptr->length); + public_key = result; + } + + // Constructor that takes a std::string for public_key + // @note private key is not set with this constructor + explicit KeyPair(const std::string &public_key_string) : public_key(public_key_string) { + BIO *bio = BIO_new_mem_buf((void *)public_key_string.c_str(), -1); + pkey.reset(PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)); + BIO_free(bio); + + if (!pkey) { + throw std::runtime_error("Failed to create public key from string"); + } + } + + inline ossl_ptr getPublicKey() { + ossl_ptr bio(BIO_new_mem_buf(public_key.c_str(), public_key.size())); + if (!bio) { + throw std::runtime_error("Unable to create BIO"); + } + + ossl_ptr key(PEM_read_bio_PUBKEY(bio.get(), NULL, NULL, NULL), false); + if (!key) { + throw std::runtime_error("Unable to read public key"); + } + + return key; + } +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_SEC_SECURITY_H diff --git a/configure/CONFIG b/configure/CONFIG index c1a470322..adcbbdba1 100644 --- a/configure/CONFIG +++ b/configure/CONFIG @@ -25,5 +25,6 @@ include $(TOP)/configure/CONFIG_SITE ifdef T_A -include $(TOP)/configure/CONFIG_SITE.Common.$(T_A) -include $(TOP)/configure/CONFIG_SITE.$(EPICS_HOST_ARCH).$(T_A) + -include $(TOP)/configure/O.$(T_A)/TOOLCHAIN endif diff --git a/configure/CONFIG_PVXS_MODULE b/configure/CONFIG_PVXS_MODULE deleted file mode 100644 index 3eda048b2..000000000 --- a/configure/CONFIG_PVXS_MODULE +++ /dev/null @@ -1,41 +0,0 @@ -# auto-compute location of this file. -# avoid need to standardize configure/RELEASE name -_PVXS := $(dir $(lastword $(MAKEFILE_LIST))) - -# we're appending so must be idempotent -ifeq (,$(_PVXS_CONF_INCLUDED)) -_PVXS_CONF_INCLUDED := YES - -ifdef T_A - -# use custom libevent2 install prefix by: -# setting LIBEVENT only for single arch build -# setting LIBEVENT_$(T_A) for each arch -# leave unset to use implicit system search path -# NOTE: only needed if not present in default search paths -LIBEVENT ?= $(LIBEVENT_$(T_A)) - -# default to bundled location if it exists -LIBEVENT_$(T_A) ?= $(wildcard $(abspath $(_PVXS)/../bundle/usr/$(T_A))) - -# apply to include search paths -INCLUDES += $(if $(LIBEVENT),-I$(LIBEVENT)/include) - -LIBEVENT_BUNDLE_LIBS += event_core -LIBEVENT_BUNDLE_LIBS_POSIX_YES = event_pthreads -LIBEVENT_BUNDLE_LIBS += $(LIBEVENT_BUNDLE_LIBS_POSIX_$(POSIX)) - -LIBEVENT_SYS_LIBS_WIN32 = bcrypt iphlpapi netapi32 ws2_32 -LIBEVENT_SYS_LIBS += $(LIBEVENT_SYS_LIBS_$(OS_CLASS)) - -LIBEVENT_BUNDLE_LDFLAGS_Darwin_NO = -Wl,-rpath,$(LIBEVENT)/lib -LIBEVENT_BUNDLE_LDFLAGS += $(LIBEVENT_BUNDLE_LDFLAGS_$(OS_CLASS)_$(STATIC_BUILD)) - -event_core_DIR = $(LIBEVENT)/lib -event_pthreads_DIR = $(LIBEVENT)/lib - -endif # T_A - -endif # _PVXS_CONF_INCLUDED - -# logic continues in RULES_PVXS_MODULE diff --git a/configure/CONFIG_SITE b/configure/CONFIG_SITE index 9bdb4af60..cf7b45fbb 100644 --- a/configure/CONFIG_SITE +++ b/configure/CONFIG_SITE @@ -35,6 +35,14 @@ CHECK_RELEASE = YES #HOST_OPT = NO #CROSS_OPT = NO +# set to NO to disable handling of $SSLKEYLOGFILE +PVXS_ENABLE_SSLKEYLOGFILE ?= YES + +# Uncomment the appropriate line or include in your private $(TOP)/../CONFIG_SITE.local +# PVXS_ENABLE_KRB_AUTH = YES +# PVXS_ENABLE_JWT_AUTH = YES +# PVXS_ENABLE_LDAP_AUTH = YES + # These allow developers to override the CONFIG_SITE variable # settings without having to modify the configure/CONFIG_SITE # file itself. diff --git a/configure/Makefile b/configure/Makefile index 0e3ee92b5..97469b655 100644 --- a/configure/Makefile +++ b/configure/Makefile @@ -1,22 +1,41 @@ TOP=.. +# step 1. Use -I... to test event-config.h +# produce configure/O.$(T_A)/TOOLCHAIN +# step 2 in setup/Makefile +_PVXS_BOOTSTRAP = YES + include $(TOP)/configure/CONFIG +# use custom libevent2 install prefix by: +# setting LIBEVENT only for single arch build +# setting LIBEVENT_$(T_A) for each arch +# leave unset to use implicit system search path +# NOTE: only needed if not present in default search paths +LIBEVENT ?= $(LIBEVENT_$(T_A)) +LIBEVENT_$(T_A) ?= $(wildcard $(abspath $(TOP)/bundle/usr/$(T_A))) + +INCLUDES += $(if $(LIBEVENT),-I$(LIBEVENT)/include) + +# use libssl in non-default location. (eg. OSX w/ brew) +OPENSSL ?= $(OPENSSL_$(T_A)) +OPENSSL_$(T_A) ?= + +INCLUDES += $(if $(OPENSSL),-I$(OPENSSL)/include) + TARGETS = $(CONFIG_TARGETS) CONFIGS += $(subst ../,,$(wildcard $(CONFIG_INSTALLS))) CFG += CONFIG_PVXS_VERSION -CFG += CONFIG_PVXS_MODULE -CFG += RULES_PVXS_MODULE include $(TOP)/configure/RULES ifdef T_A -install: $(TOP)/configure/CONFIG_SITE.Common.$(T_A) - -$(TOP)/configure/CONFIG_SITE.Common.$(T_A): toolchain.c - $(PREPROCESS.cpp) +install: TOOLCHAIN -CLEANS += ../CONFIG_SITE.Common.$(T_A) +TOOLCHAIN: toolchain.c + $(CPP) $(CPPFLAGS) $(INCLUDES) ../toolchain.c > $@.tmp + $(CPP) $(CPPFLAGS) $(INCLUDES) ../probe-openssl.c > probe-openssl.out && echo "EVENT2_HAS_OPENSSL = YES" >> $@.tmp || echo "No OpenSSL" + $(MV) $@.tmp $@ endif diff --git a/configure/probe-openssl.c b/configure/probe-openssl.c new file mode 100644 index 000000000..0dd8b72c8 --- /dev/null +++ b/configure/probe-openssl.c @@ -0,0 +1,17 @@ + +#include + +#ifndef OPENSSL_VERSION_NUMBER +# error Some antique OpenSSL version? +#endif +#if OPENSSL_VERSION_NUMBER < 0x30000000 +# error Minimum OpenSSL 3.0 +#endif + +#include + +#ifndef EVENT__HAVE_OPENSSL +# error libevent not built with OpenSSL support +#endif + +#include diff --git a/configure/toolchain.c b/configure/toolchain.c index f503a301a..51792f23b 100644 --- a/configure/toolchain.c +++ b/configure/toolchain.c @@ -1,7 +1,7 @@ #ifdef _COMMENT_ /* Compiler inspection * - * expanded as configure/CONFIG_SITE.Common.* + * expanded as configure/O.*/TOOLCHAIN */ /* GCC preprocessor drops C comments from output. * MSVC preprocessor emits C comments in output diff --git a/documentation/EPICS Security Architecture_20240909.pdf b/documentation/EPICS Security Architecture_20240909.pdf new file mode 100644 index 000000000..036d3886f Binary files /dev/null and b/documentation/EPICS Security Architecture_20240909.pdf differ diff --git a/documentation/certificate_states.png b/documentation/certificate_states.png new file mode 100644 index 000000000..3c360fae4 Binary files /dev/null and b/documentation/certificate_states.png differ diff --git a/documentation/conf.py b/documentation/conf.py index 5ae5dc690..6639eea57 100644 --- a/documentation/conf.py +++ b/documentation/conf.py @@ -31,6 +31,7 @@ def read_version(fmt): # -- Project information ----------------------------------------------------- +# TODO Update Copyright and Attribution to reflect SLAC input project = 'PVXS' copyright = time.strftime('%Y Michael Davidsaver and Osprey DCS LLC') author = 'Michael Davidsaver' diff --git a/documentation/index.rst b/documentation/index.rst index 8e660935f..0737a0a92 100644 --- a/documentation/index.rst +++ b/documentation/index.rst @@ -40,6 +40,7 @@ See :ref:`relpolicy` for details. overview netconfig + securepva example building cli diff --git a/documentation/overview.rst b/documentation/overview.rst index a4d7786e3..d80c38277 100644 --- a/documentation/overview.rst +++ b/documentation/overview.rst @@ -31,6 +31,13 @@ Four protocol operations are supported by PVXS. Get, Put, Monitor, and RPC are to the PVA protocol what GET, PUT, POST are to the HTTP protocol. +What is Secure PVAccess? +^^^^^^^^^^^^^^^^^ + +Secure PVAccess (SPVA) is the new version of the PVA protocol which supports TLS for secure communication. + +It maintains the same basic operations (Get, Put, Monitor, RPC) as PVA, but with the addition of :ref:`transport_layer_security_tls`, +and :ref:`certificate_management`. What is a PV? ^^^^^^^^^^^^^ @@ -89,6 +96,9 @@ the specific structure used. A user of the client API will interact with Value instances of these server specified structures. Conversely, a user of the server API will need to decide on which data structures to use. +PVXS now supports :ref:`secure_pvaccess` for secure communication, enhancing the security of network operations. +This includes integration with OpenSSL and new configuration options for TLS settings. + Comparison with pvDataCPP ------------------------- diff --git a/documentation/pkcs12.md b/documentation/pkcs12.md new file mode 100644 index 000000000..223c3fc79 --- /dev/null +++ b/documentation/pkcs12.md @@ -0,0 +1,104 @@ +.. _pkcs12: + +# PKCS#12 files in brief + +The following is based on a reading of [RFC7292](https://datatracker.ietf.org/doc/html/rfc7292) as of July 2014. +Also on observation of OpenSSL circa 3.1 and keytool circa Java 17. + +End users do not need to know this. + +## File Structure + +Each `PKCS#12` file contains a list of `AuthenticatedSafe` entries (aka. "Safes"). + +``` +PKCS#12 file + AuthenticatedSafe (unencrypted) + ... + AuthenticatedSafe (password encrypted) + ... + AuthenticatedSafe (public key encrypted) +``` + +Each Safe may be: unencrypted, encrypted with a password, or encrypted with a public key. + +Each Safe contains a list of `SafeBag` entries (aka. "Bags"). + +``` +PKCS#12 file + AuthenticatedSafe + *Bag + attributes... + value +``` + +Each Bag has an a list of "Attributes" and a "value". + +RFC7292 defines two attributes `friendlyName` and `localKeyId`. +Additionally, Java defines another `oracle-jdk-trustedkeyusage` or `ORACLE_TrustedKeyUsage`. + +``` +PKCS#12 file + AuthenticatedSafe + keyBag (unencrypted private key) + pkcs8ShroudedKeyBag (encrypted private key) + certBag (certificate, usually X509) + crlBag (certificate revocation list, usually X509CRL) + secretBag (arbitrary encrypted bytes) + safeContentsBag + ... recursive list of Bags +``` + +RFC7292 defines 6 types of Bag, and leaves open the possibility of more. + + +## Bag Attributes + +`friendlyName` is a string labeling a Bag. +Java keytool uses these (via. `-alias`) to distinguish multiple private keys within one file. +OpenSSL ignores them, and gets confused if multiple private keys are present. + +`localKeyId` is meant to identifies pairs of private key and certificate. + +`oracle-jdk-trustedkeyusage` has the same value as the X509 `extendedKeyUsage` extension. + +Released version of OpenSSL as of 3.1 circa Aug. 2023 [do not understand](https://github.com/openssl/openssl/issues/6684) `oracle-jdk-trustedkeyusage`. +This is feature [planned for 3.2](https://github.com/openssl/openssl/pull/19025). + +TODO: keytool has been observed setting this to "6". OpenSSL 3.2 set `anyExtendedKeyUsage`, aka. 1. + +## File Structure + +The structures of files created by `openssl pkcs12` and `keytool` are almost identical. + +For example, a file with a certificate/key pair, and an associated CA certificate is structured like: + +``` +PKCS#12 + AuthenticatedSafe (unencrypted) + pkcs8ShroudedKeyBag + attributes + friendlyName = "my:cert:name" (Java only) + localKeyId = ... (value will match the associated keyBag or pkcs8ShroudedKeyBag + value = private key... + AuthenticatedSafe (encrypted) + certBag + attributes + friendlyName = "my:cert:name" (Java only) + localKeyId = ... (value will match the associated certBag + value = X509 certificate + certBag + attributes + friendlyName = "my:ca" (Java only) + oracle-jdk-trustedkeyusage = ... (Java only) + value = X509 certificate +``` + +Notes... + +This structure leaves the friendlyName (aka `-alias`) and localKeyId associated with a private key unencrypted in all cases. + +Java keytool has been observed (after an `-importcert`) to put almost two certBag entries with the same certificate. +One with the friendlyName from `-alias` and `oracle-jdk-trustedkeyusage` set, +and a second with friendlyName set to the distinguishing name (eg. `CN=foo,O=bar`) and no `oracle-jdk-trustedkeyusage`. +keytool seems to ignore any entries without `oracle-jdk-trustedkeyusage`, but OpenSSL reads them. diff --git a/documentation/pvaencapsulation.png b/documentation/pvaencapsulation.png new file mode 100644 index 000000000..ec004802a Binary files /dev/null and b/documentation/pvaencapsulation.png differ diff --git a/documentation/pvaident.png b/documentation/pvaident.png new file mode 100644 index 000000000..c446a4a69 Binary files /dev/null and b/documentation/pvaident.png differ diff --git a/documentation/securepva.rst b/documentation/securepva.rst new file mode 100644 index 000000000..59a06670e --- /dev/null +++ b/documentation/securepva.rst @@ -0,0 +1,2238 @@ +.. _secure_pvaccess: + +Secure PVAccess - SPVA +===================== + +Secure PVAccess (SPVA) enhances the existing PVAccess protocol by integrating :ref:`transport_layer_security` (TLS) +with comprehensive :ref:`certificate_management`, enabling encrypted communication channels and authenticated connections +between EPICS clients and servers (EPICS agents). + +Key Features: + +- Encrypted communication using TLS 1.3 +- Certificate-based authentication +- Comprehensive certificate lifecycle management +- Backward compatibility with existing PVAccess deployments +- Integration with site authentication systems + +In SPVA terminology, an 'EPICS Agent' refers to any PVAccess network client. + +Note: This release requires specific unmerged changes to epics-base. + +.. _quick_start: + +Quick Start Guide +--------------- + +1. Initialise Environment +^^^^^^^^^^^^^^^^^^^^^^^ + + .. code-block:: sh + + # Set up data and configuration home if not already set + export XDG_DATA_HOME=${XDG_DATA_HOME-~/.local/share} + export XDG_CONFIG_HOME=${XDG_CONFIG_HOME-~/.config} + mkdir -p ${XDG_DATA_HOME} ${XDG_CONFIG_HOME} + + # Make working directory for building project files + export PROJECT_HOME=~/src + mkdir -p ${PROJECT_HOME} + +2. Install Requirements +^^^^^^^^^^^^^^^^^^^^^^^ + + .. code-block:: sh + + # For Debian/Ubuntu + + apt-get update + apt-get install -y \ + build-essential \ + git \ + openssl \ + libssl-dev \ + libevent-dev \ + libsqlite3-dev \ + libcurl4-openssl-dev \ + pkg-config \ + zsh + + # For RHEL/CentOS/Rocky/Alma Linux/Fedora + + dnf install -y \ + gcc-c++ \ + git \ + make \ + openssl-devel \ + libevent-devel \ + sqlite-devel \ + libcurl-devel \ + pkg-config \ + zsh + + # For macOS + + brew update + brew install \ + openssl@3 \ + libevent \ + sqlite3 \ + curl \ + pkg-config \ + zsh + + # For Alpine Linux + + apk add --no-cache \ + build-base \ + git \ + openssl-dev \ + libevent-dev \ + sqlite-dev \ + curl-dev \ + pkgconfig \ + zsh + + # For RTEMS + # First install RTEMS toolchain from https://docs.rtems.org/branches/master/user/start/ + # Then ensure these are built into your BSP: + # - openssl + # - libevent + # - sqlite + # - libcurl + # Note: RTEMS support requires additional configuration. See RTEMS-specific documentation. + +3. Build epics-base +^^^^^^^^^^^^^^^^^ + + .. code-block:: sh + + cd ${PROJECT_HOME} + git clone --branch 7.0-method_and_authority https://github.com/george-mcintyre/epics-base.git + cd epics-base + + make -j10 all + cd ${PROJECT_HOME} + +4. Configure PVXS Build +^^^^^^^^^^^^^^^^^^^^^^^ + + .. code-block:: sh + + cd ${PROJECT_HOME} + cat >> RELEASE.local <> CONFIG_SITE.local <wait(); + client.close(); + return result; + } + +Wildcard PV Support +~~~~~~~~~~~~~~~~ + +This addition is based on the Wildcard PV support included in epics-base since version 3. It +extends this support to pvxs allowing PVs to be specified as wildcard patterns. We use this +to provide individualised PVs for each certificate's status management. + +`pvxs::server::SharedWildcardPV` support for pattern-matched PV names: + + .. code-block:: c++ + + // Define a server that responds to any SEARCH request with WILDCARD:PV:<4-characters>: + // It will extract the 4-character part of the PV name as the `id` and + // the last string as the `name` + + SharedWildcardPV wildcard_pv(SharedWildcardPV::buildMailbox()); + wildcard_pv.onFirstConnect([](SharedWildcardPV &pv, const std::string &pv_name, + const std::list ¶meters) { + // Extract id and name from parameters + auto it = parameters.begin(); + const std::string &id = *it; + const std::string &name = *++it; + + // Process and post value + if (pv.isOpen(pv_name)) { + pv.post(pv_name, value); + } else { + pv.open(pv_name, value); + } + }); + wildcard_pv.onLastDisconnect([](SharedWildcardPV &pv, const std::string &pv_name, + const std::list ¶meters) { + pv.close(pv_name); + }); + + // Add wildcard PV to server + serv.addPV("WILDCARD:PV:????:*", wildcard_pv); + +.. _protocol_operation: + +Protocol Operation +---------------- + +.. _connection_establishment: + +Connection Establishment +^^^^^^^^^^^^^^^^^^^^^ + +Connections are established using TLS if at least the server side is configured for TLS. + +Prior to the TLS handshake: + +- Certificates are loaded and validated +- CA trust is verified all the way down the chain +- Both sides subscribe to certificate status where configured for their own certificate and all those in the chain +- All certificate statues are cached + +During the TLS handshake: + +- Certificates are exchanged +- Servers staple cached certificate status in handshake +- Both sides validate and verify their peer certificate against trusted root certificates + +After the TLS handshake: + +- Both sides subscribe to peer certificate status where configured +- Clients may use OCSP stapled status immediately before waiting for status monitoring results + +.. _state_machines: + +State Machines +^^^^^^^^^^^^ + +*Server TLS Context State Machine:* + +The server transitions based on: + +- Certificate validity +- CA trust status +- Certificate status monitoring results +- :ref:`configuration` options (e.g., stop_if_no_cert) + +States: + +- ``INIT``: Initial state, loads and validates certificates +- ``TCP_READY``: Responds to TCP protocol requests when certificates are valid +- ``TLS_READY``: Responds to both TCP and TLS protocol requests +- ``DEGRADED``: Fallback state for invalid certificates or missing TLS configuration + +.. image:: spva_tls_context_state_machine.png + :alt: SPVA Server TLS Context State Machine + :align: center + + +*Client TLS Context State Machine:* + +Similar to server state machine but + +- Never exits on TLS configuration issues +- Moves to ``DEGRADED`` state and continues with TCP protocol if needed + +.. image:: spva_tls_client_context_state_machine.png + :alt: SPVA Client TLS Context State Machine + :align: center + + +.. _tls_context_search_state_machine: + +Search Handler State Machines +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Server Search Handler:* + +States: + +- ``DEGRADED``: Responds only to TCP protocol requests +- ``TCP_READY``: Responds only to TCP protocol requests, ignores TLS +- ``TLS_READY``: Responds to both TCP and TLS protocol requests + +.. image:: spva_tls_context_search_states.png + :alt: SPVA Server TLS Context Search Handler State Machine + :align: center + +*Client Search Handler:* + +- Similar to server but from client perspective +- Executes ``TLS_CONNECTOR`` on successful TLS handshake +- Falls back to ``TCP_CONNECTOR`` otherwise + +.. image:: spva_tls_client_context_search_states.png + :alt: SPVA Client TLS Context Search Handler State Machine + :align: center + +.. _connection_state_machine: + +Connection State Machines +~~~~~~~~~~~~~~~~~~~~~~~ + +*Server Connection:* + +- Manages TLS handshake and certificate validation +- Monitors peer certificate status +- Continues normal operation only after successful validation + +.. image:: spva_connection_state_machines.png + :alt: SPVA Connection State Machines + :align: center + + +*Client Connection:* + +- Similar to server but verifies stapled certificates +- Destroys connection on completion + +.. image:: spva_client_connection_state_machines.png + :alt: SPVA Client Connection State Machine + :align: center + + +.. _tls_handshake: + +TLS Handshake +~~~~~~~~~~~~ + +The following diagram shows the simplified TLS handshake sequence between server and client: + +.. image:: spvaseqdiag.png + :alt: SPVA Sequence Diagram + :align: center + +1. Each agent uses an X.509 certificate for peer authentication +2. During handshake: + + - Certificates are exchanged + - Both sides verify peer certificates against trusted root certificates + - Multiple certificates may be verified in the chain to trusted CA + - Local verification checks signature, expiration, and usage flags + +3. SPVA certificates may include status monitoring extension requiring: + + - Subscription to certificate status from issuing CA's service (:ref:`pvacms`) + - Receipt of GOOD status before trust + +4. Agents subscribe to: + + - Peer's certificate status + - Own certificate status and certificate chain + +5. Servers cache and staple certificate status in handshake + +.. _online_certificate_status_protocol_OCSP: + +OCSP and Status Verification +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. _ocsp_stapling: + +OCSP Stapling +^^^^^^^^^^^^ + +OCSP Stapling optimizes certificate status verification during TLS handshake: + +.. figure:: images/ocsp_stapling.png + :width: 800px + :align: center + :name: ocsp-stapling + +- Enabled by default with status monitoring extension +- Disable using EPICS_PVAS_TLS_OPTIONS="disable_stapling" + +.. _status_verification: + +Status Verification +^^^^^^^^^^^^^^^ + +Certificate status verification occurs at several points: + +1. Initial Connection + + - Certificates are verified during TLS handshake + - Both peers verify against trusted root certificates + - Basic checks include: + + - Signature validation + - Expiration dates + - Usage flags + +2. Runtime Monitoring + + - EPICS agents subscribe to: + + - Their own certificate status + - Their certificate chain status + - Peer certificate status + - Peer certificate chain status + +3. Status Response Handling + + - If status not received: + + - Search requests are ignored + - Client retries later + + - If status not GOOD: + + - Server offers only TCP protocol + - Client fails connection validation + + - If status GOOD: + + - Server offers both TCP and TLS + - Connection proceeds normally + +4. Optimization + + - Servers cache status for stapling + - Clients can use stapled status + - Reduces initial :ref:`pvacms` requests + +.. _status_caching: + +Status Caching +^^^^^^^^^^^^ + +- Agents subscribe to peer certificate and chain status +- Status transitions trigger connection status re-evaluation +- Cached status used within validity period to reduce :ref:`pvacms` requests +- Servers staple cached status in handshake +- Clients may skip initial :ref:`pvacms` request using stapled status + +.. _certificate_file_monitoring: + +Certificate File Monitoring +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In addition to monitoring the certificates for validity and status, the EPICS agents also watch for changes to the certificate files they are using. +If a new certificate file is detected then the EPICS agent will reconfigure any existing TLS connections to use the new certificates. + + +Beacons +^^^^^^^ + +PVAccess Beacon Messages have not been upgraded to TLS support. Important considerations: + +1. Historical Use: + - Previously used to trigger resend of unanswered Search Messages + - This practice is now discouraged + - Other methods should be used to determine server status + +2. Current Behavior: + - Servers broadcast on any configured port + - Clients should not use ports directly + - Use only as server availability indicator + +3. Security Implications: + - Beacons remain unencrypted + - Do not contain sensitive information + - Cannot be used for secure discovery + +.. _protocol_debugging: + +Protocol Debugging +---------------- + +TLS Packet Inspection +^^^^^^^^^^^^^^^^^^^ + +For detailed TLS traffic analysis: + +1. Enable key logging at build time: + + - Set PVXS_ENABLE_SSLKEYLOGFILE during compilation + +2. Configure runtime logging: + + .. code-block:: sh + + export SSLKEYLOGFILE=/tmp/sslkeylog.log + +3. Configure Wireshark: + + - Edit > Preferences > Protocols > TLS + - Set "(Pre)-Master-Secret log filename" to match SSLKEYLOGFILE path + - TLS traffic will now be decrypted in Wireshark + +Debug Logging +^^^^^^^^^^^ + +Enable detailed PVXS debug logging: + +1. Environment variable method: + + .. code-block:: sh + + export PVXS_LOG="pvxs.stapling*=DEBUG" + +1. Command line option with pvxcert: + + .. code-block:: sh + + pvxcert -d ... + +New Debug Categories: + +- ``pvxs.certs.auth`` - Authentication mechanisms +- ``pvxs.auth.cfg`` - Authn configuration +- ``pvxs.auth.cms`` - CMS authentication +- ``pvxs.auth.jwt`` - JWT authentication mechanism +- ``pvxs.auth.krb`` - Kerberos authentication mechanism +- ``pvxs.auth.mon`` - Authn monitoring +- ``pvxs.auth.stat`` - Authn status +- ``pvxs.auth.std`` - Basic credentials authentication mechanism +- ``pvxs.auth.tool`` - Authn tools (``pvacert``) +- ``pvxs.certs.status`` - Certificate management +- ``pvxs.ossl.init`` - TLS initialization +- ``pvxs.ossl.io`` - TLS I/O +- ``pvxs.stapling`` - OCSP stapling + +Connection Tracing +^^^^^^^^^^^^^^^^ + +Monitor connection state transitions: + +1. Enable connection tracing: + + .. code-block:: sh + + export PVXS_LOG="pvxs.connection=DEBUG" + +2. Trace output includes: + + - Connection establishment + - State transitions + - Certificate verification + - Error conditions + + +.. _authentication_modes_and_identity: + +Authentication modes and Identity +------------------------------- + +Authentication determines the identity of a client or server. Authorization determines access rights to PV resources. +SPVA enhances :ref:`epics_security` with fine-grained control based on: + +- Authentication method - ca, x509, or anonymous +- Certificate authority - CA common name +- TLS encryption status/mode - encrypted or unencrypted (server-only, mutual, or none) +- RPC message type - for RPC messages (Can define rules but control not implemented yet) + +AuthN Modes +^^^^^^^^^^^ + +- `Mutual`: Both client and server authenticated via certificates (Secure PVAccess) +- `Server-only`: Only server authenticated via certificate (Secure PVAccess) +- `Un-authenticated`: Credentials supplied in AUTHZ message (legacy PVAccess) +- `Unknown`: No credentials (legacy PVAccess) + +.. _determining_identity: + +Determining Identity +^^^^^^^^^^^^^^^^^^^ + +Legacy PVAccess Identity +~~~~~~~~~~~~~~~~~~~~~ + +.. image:: pvaident.png + :alt: Identity in PVAccess + :align: center + +1. Optional AUTHZ message from client: + + .. code-block:: sh + + AUTHZ method: ca + AUTHZ user: george + AUTHZ host: McInPro.level-n.com + +2. Server uses PeerInfo structure: + + .. code-block:: c++ + + struct PeerInfo { + std::string peer; // network address + std::string transport; // protocol (e.g., "pva") + std::string authority; // auth mechanism + std::string realm; // authority scope + std::string account; // user name + } + +3. PeerInfo fields map to `asAddClient()` parameters for authorization + +Secure PVAccess Identity +~~~~~~~~~~~~~~~~~~~~~ + +.. image:: spvaident.png + :alt: Identity in Secure PVAccess + :align: center + +1. Identity established via X.509 certificate during TLS handshake: + + .. code-block:: sh + + CN: greg + O: SLAC.stanford.edu + OU: SLAC National Accelerator Laboratory + C: US + +2. EPICS agent verifies certificate via trust chain + +3. PeerCredentials structure provides peer information: + + .. code-block:: c++ + + struct PeerCredentials { + std::string peer; // network address + std::string iface; // network interface + std::string method; // "anonymous", "ca", or "x509" + std::string authority; // CA common name for x509 + std::string account; // Remote user account + bool isTLS; // Secure transport status + }; + +4. Extended asAddClientX() function provides enhanced authorization control + + +.. _site_authentication_methods: + +Site Authentication Methods +------------------------- + +An Authentication Method usually includes a daemon that runs on an EPICS agent machine to +monitor availability and validity of certificates and create/replace them when necessary. +This is why we call these components Authentication Daemons (AD). +Authentication daemons can also run as commandline tools to create one-off certific + +Implementing a new authentication method requires: + +Authentication Daemon (AD) Implementation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Create under ``/certs/authn/``: + +- `authnmain.cpp` - Main runner (copy from template) +- `authn.cpp` - Main implementation subclassing ``Authn`` +- `authn.h` - Header file +- `config.cpp` - Configuration interface subclassing ``AuthnConfig`` +- `config.h` - Header file +- `Makefile` - Build configuration +- `README.md` - Documentation + +CCR Message Verifier +^^^^^^^^^^^^^^^^^^^^ + +Create under `/certs/authn/`: + +- `verifier.cpp` - Verifier implementation for :ref:`pvacms` +- `verifier.h` - Header file with required macros/constants +- `VERIFIER_RULES` - Makefile rules for :ref:`pvacms` integration +- `VERIFIER_CONFIG` - Makefile configuration for :ref:`pvacms` + +Authentication Daemon Types +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. _pvacms_type_0_auth_methods: + +TYPE ``0`` - Basic Credentials +~~~~~~~~~~~~~~~~~~~~~~~ + +- Uses basic information: + + - Username + - Hostname + - Process name + - Device name + - IP address + +- No verification performed +- Certificates start in ``STATUS_CHECK_APPROVAL`` state +- Requires administrator approval + +.. _pvacms_type_1_auth_methods: + +TYPE ``1`` - Independently Verifiable Tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Tokens verified independently or via endpoint (e.g., JWT) +- Verification methods: + + - Token signature verification + - Token payload validation + - Verification endpoint calls + +.. _pvacms_type_2_auth_methods: + +TYPE ``2`` - Source Verifiable Tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Requires programmatic API integration (e.g., Kerberos) +- Adds verifiable data to :ref:`certificate_creation_request_CCR` message +- :ref:`pvacms` uses method-specific libraries for verification + + +Included Reference Authentication Daemons +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Though it is recommended that you create your own site-specific authentication methods the following ha been included +as examples of how they can be implemented into the Secure PVAccess framework. As a norm +you should generate tokens in the ``PENDING_APPROVAL`` state unless the authentication mechanism includes +a verifier. + +- ``authnstd`` : Standard - Basic credentials +- ``authnkrb`` : Kerberos - Kerberos credentials +- ``authnldap``: LDAP - Kerberos credentials verified in LDAP directory +- ``authnjwt`` : JWT - JWT tokens + +authstd Configuration and Usage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This authentication method is used for basic credentials. +It can be used to create a certificate with a username and hostname. + +- `CN` field in the certificate will be the logged in username + + - unless the EPICS_PVA_AUTH_STD_NAME, EPICS_PVAS_AUTH_STD_NAME environment variable is set + +- `O` field in the certificate will be the hostname + + - unless the EPICS_PVA_AUTH_STD_ORG, EPICS_PVAS_AUTH_STD_ORG environment variable is set + +- `OU` field in the certificate will not be set + + - unless the EPICS_PVA_AUTH_STD_ORG_UNIT, EPICS_PVAS_AUTH_STD_ORG_UNIT environment variable is set + +- `C` field in the certificate will be set to the local country code + + - unless the EPICS_PVA_AUTH_STD_COUNTRY, EPICS_PVAS_AUTH_STD_COUNTRY environment variable is set + +**usage** + +Uses the standard ``EPICS_PVA_TLS_`` environment variables to determine the certificate file, +private key, and password file locations. + + .. code-block:: sh + + Usage: authnstd + + -v Make more noise. + -h Show this help message and exit + -d Shorthand for $PVXS_LOG="pvxs.*=DEBUG". Make a lot of noise. + -D Run in Daemon mode. Monitors and updates certs as needed + -V Show version and exit + -u Usage. client, server, or gateway + -N Name override the CN subject field + -O Org override the O subject field + -o Override the OU subject field + + ENVIRONMENT VARIABLES: at least one mandatory variable must be set + EPICS_PVA_TLS_KEYCHAIN Set name and location of client certificate file (mandatory for clients) + EPICS_PVAS_TLS_KEYCHAIN Set name and location of server certificate file (mandatory for server) + EPICS_PVA_TLS_KEYCHAIN_PWD_FILE Set name and location of client certificate password file (optional) + EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE Set name and location of server certificate password file (optional) + EPICS_PVA_TLS_PKEY Set name and location of client private key file (optional) + EPICS_PVAS_TLS_PKEY Set name and location of server private key file (optional) + EPICS_PVA_TLS_PKEY_PWD_FILE Set name and location of client private key password file (optional) + EPICS_PVAS_TLS_PKEY_PWD_FILE Set name and location of server private key password file (optional) + +**Environment Variables for authnstd** + ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +| Name | Keys and Values | Description | ++======================+====================================+=======================================================================+ +|| EPICS_AUTH_STD || || Amount of minutes before the certificate expires. | +|| _CERT_VALIDITY_MINS || e.g. ``525960`` for 1 year || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_AUTH_STD || {name to use} || Name to use in new certificates | +|| _NAME || e.g. ``archiver`` || | ++----------------------+ e.g. ``IOC1`` || | +|| EPICS_PVAS_AUTH_STD || e.g. ``greg`` || | +|| _NAME || || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_AUTH_STD || {organization to use} || Organization to use in new certificates | +|| _ORG || e.g. ``site.epics.org`` || | ++----------------------+ e.g. ``SLAC.STANFORD.EDU`` || | +|| EPICS_PVAS_AUTH_STD || e.g. ``KLYS:LI01:101`` || | +|| _ORG || e.g. ``centos07`` || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_AUTH_STD || {organization unit to use} || Organization Unit to use in new certificates | +|| _ORG_UNIT || e.g. ``data center`` || | ++----------------------+ e.g. ``ops`` || | +|| EPICS_PVAS_AUTH_STD || e.g. ``prod`` || | +|| _ORG_UNIT || e.g. ``remote`` || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_AUTH_STD || {country to use} || Country to use in new certificates. | +|| _COUNTRY || e.g. ``US`` || Must be a two digit country code | ++----------------------+ e.g. ``CA`` || | +|| EPICS_PVAS_AUTH_STD || || | +|| _COUNTRY || || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_TLS || || The location of the keychain file. The file will be created here | +|| _TLS_KEYCHAIN || || | ++----------------------+ || | +|| EPICS_PVAS_TLS || || | +|| _TLS_KEYCHAIN || || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_TLS || || The location of the file containing the password for the keychain | +|| _KEYCHAIN_PWD_FILE || || file. | ++----------------------+ || | +|| EPICS_PVAS_TLS || || | +|| _KEYCHAIN_PWD_FILE || || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_TLS || || The location of the private key file. The file will be created here | +|| _TLS_PKEY || || | ++----------------------+ || | +|| EPICS_PVAS_TLS || || | +|| _TLS_PKEY || || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ +|| EPICS_PVA_TLS || || The location of the file containing the password for the private key | +|| _PKEY_PWD_FILE || || file. | ++----------------------+ || | +|| EPICS_PVAS_TLS || || | +|| _PKEY_PWD_FILE || || | ++----------------------+------------------------------------+-----------------------------------------------------------------------+ + +**Examples** + + .. code-block:: sh + + # create a client certificate for greg@slac.stanford.edu + authnstd -u client -N greg -O slac.stanford.edu + + .. code-block:: sh + + # create a server certificate for IOC1 + authnstd -u server -N IOC1 -O "KLI:LI01:10" -o "FACET" + + + .. code-block:: sh + + # create a gateway certificate for gateway1 + authnstd -u gateway -N gateway1 -O bridge.ornl.gov -o "Networking" + + +authkrb Configuration and Usage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This authentication method is a TYPE ``2`` authentication method. +It can be used to create a certificate from a Kerberos ticket. + +A user will need to have a Kerberos ticket to use this authentication method typically +using the ``kinit`` command. + + .. code-block:: sh + + kinit -l 24h greg@SLAC.STANFORD.EDU + +- `CN` field in the certificate will be kerberos username +- `O` field in the certificate will be the kerberos realm +- `OU` field in the certificate will not be set +- `C` field in the certificate will be set to the local country code + + +**usage** + +Uses the standard ``EPICS_PVA_TLS_`` environment variables to determine the certificate file, +private key, and password file locations. + + .. code-block:: sh + + authnkrb + + Options: + -h show help + -v verbose output + -t {client | server} Client or server certificate certificate type + -C Create a certificate and exit + -D Start authentication daemon to monitor certificate files and certificate status. + Will attempt to install a new certificate if the existing one expires, + or if the kerberos ticket expires and is renewable, + or if the certificate file is deleted, or if the certificate is REVOKED. + + + +**Environment Variables for PVACMS AuthnKRB Verifier** + +The environment variables in the following table configure the Kerberos +Credentials Verifier for :ref:`pvacms` at runtime. + + ++-----------------+--------------------------------------+---------------------------------------------------------------------+ +| Name | Keys and Values | Description | ++=================+======================================+=====================================================================+ +|| EPICS_AUTH_KRB || {string location of keytab file} || This is the keytab file shared with :ref:`pvacms` by the KDC so . | +|| _KEYTAB || e.g. ``/etc/security/keytab`` || that it can verify kerberos tickets | ++-----------------+--------------------------------------+---------------------------------------------------------------------+ +|| EPICS_AUTH_KRB || {this is the kerberos realm to use} || This is the kerberos realm to use when verifying kerberos tickets. | +|| _REALM || e.g. ``SLAC.STANFORD.EDU`` || Overrides the verifier fields if specified. | ++-----------------+--------------------------------------+---------------------------------------------------------------------+ + + +authldap Configuration and Usage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This authentication method is a TYPE ``2`` authentication method. +It can be used to create a certificate from a Kerberos ticket that is +verified against an LDAP server. + +A user will need to have a Kerberos ticket to use this authentication method typically +using the ``kinit`` command. + + .. code-block:: sh + + kinit -l 24h greg@SLAC.STANFORD.EDU + +- `CN` field in the certificate will be kerberos username +- `O` field in the certificate will be the kerberos realm +- `OU` field in the certificate will not be set +- `C` field in the certificate will be set to the local country code + + +**usage** + +Uses the standard ``EPICS_PVA_TLS_`` environment variables to determine the certificate file, +private key, and password file locations. + + .. code-block:: sh + + authnkrb + + Options: + -h show help + -v verbose output + -t {client | server} Client or server certificate certificate type + -C Create a certificate and exit + -D Start authentication daemon to monitor certificate files and certificate status. + Will attempt to install a new certificate if the existing one expires, + or if the kerberos ticket expires and is renewable, + or if the certificate file is deleted, or if the certificate is REVOKED. + + +**Environment Variables for PVACMS AuthnLDAP Verifier** + +The environment variables in the following table configure the +LDAP Credentials Verifier for :ref:`pvacms` at runtime in addition to the AuthnKrb environment variables. + ++--------------------+---------------------------------------+------------------------------------------------------------+ +| Name | Keys and Values | Description | ++====================+=======================================+============================================================+ +|| EPICS_AUTH_LDAP || || The admin account to use to access the LDAP server. | +|| _ACCOUNT || e.g. ``admin`` || when verifying LDAP credentials. | ++--------------------+---------------------------------------+------------------------------------------------------------+ +|| EPICS_AUTH_LDAP || {location of password file} || file containing password for the given LDAP admin account | +|| _ACCOUNT_PWD_FILE || e.g. ``~/.config/ldap.pass/`` || | ++--------------------+---------------------------------------+------------------------------------------------------------+ +|| EPICS_AUTH_LDAP || {hostname of LDAP server} || Trusted hostname of the LDAP server | +|| _HOST || e.g. ``ldap.stanford.edu`` || | ++--------------------+---------------------------------------+------------------------------------------------------------+ +|| EPICS_AUTH_LDAP || || LDAP server port number. Default is 389 | +|| _PORT || e.g. ``389`` || | ++--------------------+---------------------------------------+------------------------------------------------------------+ +|| EPICS_AUTH_LDAP || {LDAP directory name to search from} || LDAP directory name to search from. | +|| _SEARCH_ROOT || e.g. ``dc=slac,dc=stanford,dc=edu`` || | ++--------------------+---------------------------------------+------------------------------------------------------------+ + + +authjwt Configuration and Usage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This authentication method is a TYPE ``1`` authentication method. +It can be used to create a certificate from a JWT token. + +The daemon will create a rest service that will allow posting of JWT tokens +and create a certificate based on the token's credentials. + +Verification of the JWT token is performed by :ref:`pvacms` before exchanging for a certificate. + +**JWT Token Post Request** +A web application, python script, java application, etc. can post a JWT token to the authentication daemon +whenever it gets a new token from an authentication service. The authentication daemon will send +a :ref:`certificate_creation_request_CCR` to :ref:`pvacms` to create a certificate based on the JWT token. :ref:`pvacms` will verify the token based +on the configuration of the authnjwt verifier. + +You could test this by posting a JWT token to the authentication daemon as follows: + + .. code-block:: sh + + authnjwt -D & + + curl -X POST http://localhost:8080 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" + +.. note:: + + No body is sent in this POST request. + +- `CN` field in the certificate will be the username from the JWT token +- `O` field in the certificate will be the issuer from the JWT token +- `OU` field in the certificate will not be set +- `C` field in the certificate will be set to the local country code + + +**usage** + +Uses the standard ``EPICS_PVA_TLS_`` environment variables to determine the certificate file, +private key, and password file locations. + + .. code-block:: sh + + authnjwt + + Options: + -h show help + -v verbose output + -t {client | server} Client or server certificate certificate type + -C Create a certificate and exit + -D Start authentication daemon web service to receive + JWT tokens and create certificates. + +**Environment Variables for PVACMS AuthnJWT Verifier** + +The environment variables in the following table configure the JWT +Credentials Verifier for :ref:`pvacms` at runtime. + ++---------------------+---------------------------------------------------+-------------------------------------------------------------------------------------+ +| Name | Keys and Values | Description | ++=====================+===================================================+=====================================================================================+ +|| EPICS_AUTH_JWT || {string format for verification request payload} || Used to create the verification request payload by substituting the #token# | +|| _REQUEST_FORMAT || e.g. ``{ "token": "#token#" }`` || for the token value, and #kid# for the key id. This is used when the | +|| || e.g. ``#token#`` || verification server requires a formatted payload for the verification request. | ++---------------------+---------------------------------------------------+-------------------------------------------------------------------------------------+ +|| EPICS_AUTH_JWT || {string format for verification response value} || A pattern string that we can use to decode the response from a verification | +|| _RESPONSE_FORMAT || || endpoint if the response is formatted text. All white space is removed in the | +|| || || given string and in the response. Then all the text prior to #response# is matched | +|| || || and removed from the response and all the text after the response is likewise | +|| || || removed, what remains is the response value. An asterisk in the string matches | +|| || || any sequence of characters in the response. It is converted to lowercase and | +|| || || interpreted as valid if it equals valid, ok, true, t, yes, y, or 1. | ++---------------------+---------------------------------------------------+-------------------------------------------------------------------------------------+ +|| EPICS_AUTH_JWT || {uri of JWT validation endpoint} || Trusted URI of the validation endpoint – the substring that starts the URI | +|| _TRUSTED_URI || || including the http://, https:// and port number. | ++---------------------+---------------------------------------------------+-------------------------------------------------------------------------------------+ +|| EPICS_AUTH_JWT_USE || case insensitive: ``YES``, ``TRUE``, or ``1`` || If set this tells :ref:`pvacms` that when it receives a 200 HTTP-response from | +|| _RESPONSE_CODE || || the HTTP request then the token is valid, and invalid for any other response code. | ++---------------------+---------------------------------------------------+-------------------------------------------------------------------------------------+ +|| EPICS_AUTH_JWT || {``POST`` (default) or ``GET``} || This determines whether the endpoint will be called with HTTP GET or POST. | +|| _REQUEST_METHOD || || | ++---------------------+---------------------------------------------------+-------------------------------------------------------------------------------------+ + + +.. _epics_security: + +EPICS Security +-------------- + +New AUTHORIZATION mechanisms integrate with EPICS Security through four access control mechanisms: + +METHOD +^^^^^^ + +Defines access permissions based on authentication method: + +- ``x509``: Certificate-based authentication +- ``ca``: Legacy PVAccess AUTHZ with user-specified account +- ``anonymous``: Access without specified name + +AUTHORITY +^^^^^^^^^ + +Defines access permissions based on certificate authority: + +- Uses CA name from ``CN`` field of CA certificate's subject +- Only applicable for X.509 certificate authentication + +RPC Permission +^^^^^^^^^^^^^^^ + +New rule permission for RPC message access control: + +- Supplements existing ``NONE``, ``READ`` (`GET`), and ``WRITE`` (`PUT`) +- Controls access to `RPC` PVAccess messages + +ISTLS Option +^^^^^^^^^^^^^ + +New rule option for TLS-based access control: + +- Requires server connection with trusted CA-signed certificate +- Enables READ access restriction to certified PVs only + +.. _access_control_file_ACF: + +Access Control File (ACF) +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Example ACF showing new security features: + + .. code-block:: text + + UAG(bar) {boss} + UAG(foo) {testing} + UAG(ops) {geek} + + ASG(DEFAULT) { + RULE(0,NONE,NOTRAPWRITE) + } + + ASG(ro) { + RULE(0,NONE,NOTRAPWRITE) + RULE(1,READ,ISTLS) { + UAG(foo,ops) + METHOD("ca") + } + } + + ASG(rw) { + RULE(0,NONE,NOTRAPWRITE) + RULE(1,WRITE,TRAPWRITE) { + UAG(foo) + METHOD("x509") + AUTHORITY("Epics Org CA") + } + } + + ASG(rwx) { + RULE(0,NONE,NOTRAPWRITE) + RULE(1,RPC,NOTRAPWRITE) { + UAG(bar) + METHOD("x509") + AUTHORITY("Epics Org CA","ORNL Org CA") + } + } + +.. _new_epics_yaml_acf_file_format: + +EPICS YAML ACF Format +^^^^^^^^^^^^^^^^^^^ + +Alternative YAML format for improved readability: + + .. code-block:: yaml + + # EPICS YAML + version: 1.0 + + uags: + - name: bar + users: + - boss + - name: foo + users: + - testing + - name: ops + users: + - geek + + asgs: + - name: ro + rules: + - level: 0 + access: NONE + trapwrite: false + - level: 1 + access: READ + isTLS: true + uags: + - foo + - ops + methods: + - ca + + - name: rw + rules: + - level: 0 + access: NONE + trapwrite: false + - level: 1 + access: WRITE + trapwrite: true + uags: + - foo + methods: + - x509 + authorities: + - SLAC Certificate Authority + + - name: rwx + rules: + - level: 0 + access: NONE + trapwrite: false + - level: 1 + access: RPC + trapwrite: true + uags: + - bar + methods: + - x509 + authorities: + - SLAC Certificate Authority + - ORNL Org CA + + +.. _certificate_management: + +Certificate Management +-------------------- + +Certificate States +^^^^^^^^^^^^^^^^^ + +.. figure:: certificate_states.png + :alt: Certificate States + :width: 800px + :align: center + :name: certificate-states + +- ``PENDING_APPROVAL``: Certificate awaiting administrative approval +- ``PENDING``: Certificate not yet valid (before notBefore date) +- ``VALID``: Certificate currently valid and usable +- ``EXPIRED``: Certificate expired (after notAfter date) +- ``REVOKED``: Certificate permanently revoked by administrator + +.. _certificate_status_message: + +Certificate Status Message +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Status response structure: + + .. code-block:: console + + Structure + enum_t status # PENDING_APPROVAL, PENDING, VALID, EXPIRED, REVOKED + UInt64 serial # Certificate serial number + string state # String representation of status + enum_t ocsp_status # GOOD, REVOKED, UNKNOWN + string ocsp_state # OCSP state string + string ocsp_status_date # Status timestamp + string ocsp_certified_until # Validity period end + string ocsp_revocation_date # Revocation date if applicable + UInt8A ocsp_response # Signed PKCS#7 encoded OCSP response + +.. _certificate_creation_request_CCR: + +Certificate Creation Request (CCR) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This message is sent to :ref:`pvacms` to create a new certificate. It is a PVStructure with the following fields: + +Request structure: + + .. code-block:: console + + Structure + string type # std, krb, ldap, jwt + string name # Certificate subject name + string country # Optional: Country code + string organization # Optional: Organization name + string organization_unit # Optional: Unit name + UInt16 usage # Certificate usage flags: + # 0x01: Client + # 0x02: Server + # 0x03: Client and Server + # 0x04: Intermediate CA + # 0x08: CMS + # 0x0A: Any Server + # 0x10: CA + UInt32 not_before # Validity start time (epoch seconds) + UInt32 not_after # Validity end time (epoch seconds) + string pub_key # Public key data + enum_t status_monitoring_extension # Include status monitoring + structure verifier # Optional: Authentication data + +The ``verifier`` sub-structure is only present if the ``type`` field references a + :ref:`pvacms_type_1_auth_methods`, or :ref:`pvacms_type_2_auth_methods` authentication mechanism. + + +Certificate Management Operations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``pvacert`` can be used to `APPROVE`, `DENY`, and `REVOKE` certificates as follows. + +Approval: + + .. code-block:: sh + + pvxcert -A # Approve certificate + +Denial: + + .. code-block:: sh + + pvxcert -D # Deny certificate (sets REVOKED) + +Revocation: + + .. code-block:: sh + + pvxcert -R # Permanently revoke certificate + +It achieves this by using `PUT` to send a PVStructure with the following fields, to :ref:`pvacms` +on the PV associated with the certificate: + + .. code-block:: console + + Structure + string state # APPROVE, DENY, REVOKE + + +.. _certificates_and_private_keys: + +Certificates and Private Keys +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +EPICS Agents maintain public/private key pairs for identification: + +- Public key identifies agent to peers (8-character SKID) +- Private key must be protected like a password + +Identity Assertion Process: + +1. Agent presents certificate to peer +2. Agent signs data with private key +3. Peer verifies signature using public key +4. Peer validates certificate trust chain to CA +5. Identity confirmed through successful verification + +Key Security: + +- Private key protection is critical +- Store in protected keychain file +- Use separate keychain files for each certificate + + +Certificate Management Tools +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +pvxcert +^^^^^^^ + + .. code-block:: console + + Usage: pvxcert [OPTIONS] [cert_id] + pvxcert [OPTIONS] -f [cert-file] [-p] + pvxcert -I + + POSITIONALS: + cert_id TEXT Certificate ID + + OPTIONS: + -h, --help Print this help message and exit + -w, --timeout FLOAT [5] Operation timeout in seconds + -v, --verbose Make more noise + -d, --debug Shorthand for $PVXS_LOG="pvxs.*=DEBUG". Make a lot of noise. + -f, --file TEXT The certificate file to read if no Certificate ID specified + -p, --password Prompt for password + -V, --version Print version and exit. + -#, --limit UINT [20] Maximum number of elements to print for each array field. Set to + zero 0 for unlimited + -F, --format TEXT Output format mode: delta, tree + -I, --install Download and install the root certificate + -A, --approve APPROVE the certificate (ADMIN ONLY) + -R, --revoke REVOKE the certificate (ADMIN ONLY) + -D, --deny DENY the pending certificate (ADMIN ONLY) + +Key Operations: + +- Install root certificates in trusted store +- Check certificate status +- Approve/deny STATUS_CHECK_APPROVAL certificates (admin) +- Revoke certificates in any state (admin) + +Certificate Usage +^^^^^^^^^^^^^^^^^ + +Network clients can request new certificates from :ref:`pvacms` using their public key. The process: + +1. Generate key pair +2. Submit certificate request +3. Receive signed certificate +4. Install in configured location + + +.. _pvacms: + +PVACMS +^^^^^^ + +The :ref:`pvacms` is the Certificate Authority Service for the EPICS Secure PVAccess Network. + + +.. _pvacms_usage: + +PVACMS Usage +~~~~~~~~~~~~ + + .. code-block:: console + + PVACMS - Certificate Management Service + + pvacms [OPTIONS] + + OPTIONS: + -h, --help Show this message + -v, --verbose Make more noise + -V, --version Print version and exit. + --ck, --ca-keychain TEXT [/Users/george/.epics/certs/ca.pem] + Specify CA keychain file location + --cpk, --ca-private-key TEXT + Specify CA private key file location + --ckp, --ca-keychain-pwd TEXT + Specify CA keychain password file location + --cpkp, --ca-private-key-pwd TEXT + Specify CA private key password file location + --pk, --pvacms-keychain TEXT [/Users/george/.epics/certs/pvacms.pem] + Specify PVACMS keychain file location + --ppk, --pvacms-private-key TEXT + Specify PVACMS private key file location + --pkp, --pvacms-keychain-pwd TEXT + Specify PVACMS keychain password file location + --ppkp, --pvacms-private-key-pwd TEXT + Specify PVACMS private key password file location + --ak, --admin-keychain TEXT + Specify PVACMS admin user's keychain file location + --apk, --admin-private-key TEXT + Specify PVACMS admin user's private key file location + --akp, --admin-keychain-pwd TEXT + Specify PVACMS admin user's keychain password file location + --apkp, --admin-private-key-pwd TEXT + Specify PVACMS admin user's private key password file location + --cn, --ca-name TEXT [EPICS Root CA] + Specify the CA's name. Used if we need to create a root + certificate + --co, --ca-org TEXT [ca.epics.org] + Specify the CA's Organization. Used if we need to create a root + certificate + --cou, --ca-org-unit TEXT [EPICS Certificate Authority] + Specify the CA's Organization Unit. Used if we need to create a + root certificate + --cc, --ca-country TEXT + Specify the CA's Country. Used if we need to create a root + certificate + --pn, --pvacms-name TEXT [PVACMS] + Specify the PVACMS name. Used if we need to create a PVACMS + certificate + --po, --pvacms-org TEXT [ca.epics.org] + Specify the PVACMS Organization. Used if we need to create a + PVACMS certificate + --pou, --pvacms-org-unit TEXT [EPICS Certificate Authority] + Specify the PVACMS Organization Unit. Used if we need to create a + PVACMS certificate + --pc, --pvacms-country TEXT + Specify the PVACMS Country. Used if we need to create a PVACMS + certificate + -s, --acf TEXT [/Users/george/.epics/auth/pvacms.acf] + Access security Configuration File + -d, --cert-db TEXT [/Users/george/.epics/db/certs.db] + Specify cert db file location + --client-require-approval BOOLEAN [1] + Generate Client Certificates in PENDING_APPROVAL state + --server-require-approval BOOLEAN [1] + Generate Server Certificates in PENDING_APPROVAL state + --gateway-require-approval BOOLEAN [1] + Generate Server Certificates in PENDING_APPROVAL state + --svm, --status-validity-mins UINT [30] + Set Status Validity Time in Minutes + --sme, --status-monitoring-enabled BOOLEAN [1] + Require Peers to monitor Status of Certificates Generated by this + server by default. Can be overridden in each CCR + +.. _pvacms_configuration: + +PVACMS Configuration +~~~~~~~~~~~~~~~~~~~ + +The environment variables in the following table configure the :ref:`pvacms` at runtime. + +.. note:: + There is also an implied hierarchy to their applicability such that :ref:`pvacms` + supersedes the PVAS version which in turn, supersedes the PVA version. + So, if a :ref:`pvacms` wants to specify its keychain file location it can simply + provide the ``EPICS_PVA_TLS_KEYCHAIN`` environment variable as long as neither + ``EPICS_PVACMS_TLS_KEYCHAIN`` nor ``EPICS_PVAS_TLS_KEYCHAIN`` are configured. + ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +| Name | Keys and Values | Description | ++========================+============================================+==========================================================================+ +|| EPICS_CA_KEYCHAIN || || fully qualified path to a file that will be used as the | +|| || e.g. ``~/.config/cacert.p12`` || CA keychain file. | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_CA_KEYCHAIN || || fully qualified path to a file that will be used as the | +|| _PWD_FILE || e.g. ``~/.config/cacert.pass`` || CA keychain password file. | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_CA_PKEY || || fully qualified path to a file that will be used as the | +|| || e.g. ``~/.config/cakey.p12`` || CA private key file. Use same EPICS_CA_KEYCHAIN file if not specified | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_CA_PKEY || || fully qualified path to a file that will be used as the | +|| _PWD_FILE || e.g. ``~/.config/cakey.pass`` || CA private key password file if specified. | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_CA_NAME || || To provide the name (CN) to be used in the subject of the | +|| || e.g. ``Epics Root CA`` || CA's certificate if :ref:`pvacms` creates it. default: "EPICS Root CA" | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_CA || || To provide the name (O) to be used in the subject of the CA's | +|| _ORGANIZATION || e.g. ``ca.epics.org`` || certificate if :ref:`pvacms` creates it. default: "ca.epics.org" | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_CA || || To provide the name (OU) to be used in the subject of the CA's | +|| _ORGANIZATIONAL_UNIT || e.g. ``EPICS Certificate Authority`` || certificate if :ref:`pvacms` creates it. | +|| || || default: "EPICS Certificate Authority" | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_ACF || || fully qualified path to a file that will be used as the | +|| || e.g. ``~/.config/pvacms.acf`` || ACF file that configures the permissions of :ref:`pvacms` peers. | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_ADMIN_TLS || || The location of the :ref:`pvacms` ADMIN user keychain file. | +|| _KEYCHAIN || e.g. ``~/.config/pvacms.p12`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_ADMIN_TLS || || Location of a password file for :ref:`pvacms` ADMIN user keychain file. | +|| _KEYCHAIN_PWD_FILE || e.g. ``~/.config/pvacms.pass`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_ADMIN_TLS || || The location of the :ref:`pvacms` ADMIN user private key file. | +|| _PKEY || e.g. ``~/.config/pvacmskey.p12`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_ADMIN_TLS || || Location of password file for :ref:`pvacms` ADMIN user private key file | +|| _PKEY_PWD_FILE || e.g. ``~/.config/adminkey.pass`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_CERT || || Minutes that the ocsp status response will | +|| _STATUS_VALIDITY_MINS || e.g. ``30`` || be valid before a client must re-request an update | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_CERTS || {``true`` (default) or ``false``} || For authnstd: ``true`` if we require peers to | +|| _REQUIRE_SUBSCRIPTION || || subscribe to certificate status for certificates to | +|| || || be deemed VALID. Adds extension to new certificates | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_DB || || fully qualified path to a file that will be used as the | +|| || e.g. ``~/.local/share/certs.db`` || CA database file. | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_REQUIRE || {``true`` (default) or ``false`` } || ``true`` if server should generate new client certificates in the | +|| _CLIENT_APPROVAL || || ``PENDING_APPROVAL`` state ``false`` to generate in the ``VALID`` state | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_REQUIRE || {``true`` (default) or ``false`` } || ``true`` if server should generate new gateway certificates in the | +|| _GATEWAY_APPROVAL || || ``PENDING_APPROVAL`` state ``false`` to generate in the ``VALID`` state | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_REQUIRE || {``true`` (default) or ``false`` } || ``true`` if server should generate new server certificates in the | +|| _SERVER_APPROVAL || || ``PENDING_APPROVAL`` state ``false`` to generate in the ``VALID`` state | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_STATUS || {string prefix for certificate status PV} || This replaces the default ``CERT:STATUS`` prefix. | +|| _PV_ROOT || e.g. ``:ref:`pvacms`:STATUS`` || will be followed by ``:????????:*`` pattern | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_TLS || || The location of the :ref:`pvacms` keychain file. | +|| _KEYCHAIN || e.g. ``~/.config/pvacms.p12`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_TLS || || Location of a password file for :ref:`pvacms` keychain file. | +|| _KEYCHAIN_PWD_FILE || e.g. ``~/.config/pvacms.pass`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_TLS || || The location of the :ref:`pvacms` private key file. | +|| _PKEY || e.g. ``~/.config/pvacmskey.p12`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_TLS || || Location of a password file for :ref:`pvacms` private key file. | +|| _PKEY_PWD_FILE || e.g. ``~/.config/pvacmskey.pass`` || | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ +|| EPICS_PVACMS_TLS || {``true`` or ``false`` (default) } || ``true`` if server should stop if no cert is available or can be | +|| _STOP_IF_NO_CERT || || verified if status check is enabled | ++------------------------+--------------------------------------------+--------------------------------------------------------------------------+ + +Extensions to Config for PVACMS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- `cert_status_validity_mins` + - The number of minutes that the certificate status is valid for. + - Default: 30 +- `cert_client_require_approval` + - If ``true`` then authstd (basic authentication) generated client certificates must be approved before they can be used. + - Default: ``true`` +- `cert_server_require_approval` + - If ``true`` then authstd (basic authentication) generated server certificates must be approved before they can be used. + - Default: ``true`` +- `cert_status_subscription` + - If ``Yes`` then the :ref:`pvacms` will embed the certificate status monitoring extension in all certificates it issues by default. + - If ``Always`` then force ``Yes`` irrespective of the :ref:`certificate_creation_request_CCR` ``status_monitoring_extension`` field. + - If ``No`` then do not embed the certificate status monitoring extension in certificates it issues by default. + - If ``Never`` then force ``No`` irrespective of the :ref:`certificate_creation_request_CCR` ``status_monitoring_extension`` field. + - Default: ``Yes`` - overrides ``EPICS_PVACMS_STATUS_SUBSCRIPTION`` environment variable. +- `ca_db_filename` + - The CA database file location. + - Default: ``certs.db`` +- `ca_cert_filename` + - The CA certificate file location. +- `ca_cert_password` + - The CA certificate password. +- `ca_private_key_filename` + - The CA private key file location. +- `ca_private_key_password` + - The CA private key password. +- `ca_acf_filename` + - The CA access control file location. This file protects the :ref:`pvacms` administrator access. +- `ca_name` + - The CA name - used to create the CA certificate if it does not already exist. + - Default: ``"EPICS Root CA`` +- `ca_organization` + - The CA organization - used to create the CA certificate if it does not already exist + - Default: ``ca.epics.org`` +- `ca_organization_unit` + - The CA organizational unit - used to create the CA certificate if it does not already exist + - Default: ``EPICS Certificate Authority`` + + +PVACMS Authorization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A default ACF file is generated when PVACMS starts up for the first time. +It contains a user group named for the SKID - Subject Key Identifier - of the +root CA. It has one single user called `admin`. It defines +an access rule that allows users in this group `WRITE` access +to the Certificate Status PVs so that the state of certificates +can be managed. Only Users that have been verified by the +certificate authority that the PVACMS manages are authorized. + + .. code-block:: text + + UAG(fedcba98) {admin} + + ASG(DEFAULT) { + RULE(0,READ) + RULE(1,WRITE) { + UAG(admin) + METHOD("x509") + AUTHORITY("Epics Org CA") + } + } + +Equivalent YAML format: + + .. code-block:: yaml + + # EPICS YAML + version: 1.0 + + uags: + - name: fedcba98 + users: + - admin + + asgs: + - name: DEFAULT + rules: + - level: 0 + access: READ + - level: 1 + access: WRITE + uags: + - fedcba98 + methods: + - x509 + authorities: + - Epics Org CA + +A default client certificate is generated that matches this security privilege. +This certificate has the subject CN name `admin` and is generated by the Certificate Authority +associated with this PVACMS. By default the certificate and key are stored in the file admmin.p12 +in the current working directory. + + .. code-block:: console + + 2025-06-08T18:00:49.487647000 INFO pvxs.certs.cms X.509 CA certificate + 2025-06-08T18:00:49.487665000 INFO pvxs.certs.cms CERT_ID: fedcba98:13822586378443716801 + 2025-06-08T18:00:49.487693000 INFO pvxs.certs.cms NAME: admin + 2025-06-08T18:00:49.487708000 INFO pvxs.certs.cms ORGANIZATION: + 2025-06-08T18:00:49.487731000 INFO pvxs.certs.cms ORGANIZATIONAL UNIT: + 2025-06-08T18:00:49.487746000 INFO pvxs.certs.cms STATUS: VALID + 2025-06-08T18:00:49.487758000 INFO pvxs.certs.cms VALIDITY: Sun Jun 8 18:00:49 2025 to Fri Jun 8 18:00:49 2029 + + admin.p12 + +Using this certificate an administrator can `Approve` or `Deny` +certificates in the ``PENDING_APPROVAL`` state and `Revoke` ``VALID`` ones. + + .. code-block:: shell + + # Approve PENDING_APPROVAL certificate 3519231305961542464 + pvxcert fedcba98:3519231305961542464 -A + + # Deny PENDING_APPROVAL certificate 3519231305961542464 + pvxcert fedcba98:3519231305961542464 -D + + # Revoke VALID certificate 3519231305961542464 + pvxcert fedcba98:3519231305961542464 -R + +.. _network_deployment: + +Network Deployment +---------------- + +Deployment Patterns +^^^^^^^^^^^^^^^^^ + +1. Standard Network Deployment + + - Agents run on networked hosts with local storage + - Certificates stored in local protected directories + - Standard TLS configuration applies + +2. Diskless Network Deployment + + - Agents run on hosts without local storage + - Certificates stored on network-mounted storage + - Special considerations for certificate protection + +3. Hybrid Deployment + + - Mix of standard and diskless nodes + - Common trust anchor required + - Consistent :ref:`certificate_management` across node types + +Certificate Storage +^^^^^^^^^^^^^^^^ + +Standard Nodes: + +- Store certificates in local protected directory +- Monitor certificate files for changes +- Automatic reconfiguration on certificate updates + +Diskless Nodes: + +- Use network-mounted storage (NFS, SMB/CIFS, AFP) +- Protected certificate storage location +- Optional password protection via diskless server +- Authentication Daemon manages certificate lifecycle + +Trust Establishment +^^^^^^^^^^^^^^^ + +1. Root Certificate Distribution: + + - Install during node boot process, or + - Use publicly signed root certificates + - Consistent across all deployment types + +2. Certificate Authority: + + - :ref:`pvacms` serves as site CA + - Common trust anchor for all nodes + - Handles certificate lifecycle management + + +.. _glossary: + +Glossary +-------- + +.. _glossary_auth_vs_authz: + +- Auth or AuthN (Authentication) vs AuthZ (Authorization). + In cybersecurity, these abbreviations are commonly used to differentiate between two distinct aspects of the security process. + + - ``Authentication`` refers to the process of verifying the validity of the credentials and claims presented within a security token, ensuring that the entity is who or what it claims to be. + - ``Authorization``, on the other hand, is the process of determining and granting the appropriate access permissions to resources based on the authenticated entity's credentials and associated privileges. + +.. _glossary_certificate_authority: + +- CA – Certificate Authority. + An entity that signs, and issues digital certificates. Each site where EPICS is installed will use the proposed PVACMS as their CA. + +.. _glossary_certificate_subject: + +- Certificate’s Subject. + This is a way of referring to all the fields in the X.509 certificate that identify the entity. These are:- + + - ``CN``: common name e.g. ``slac.stanford.edu``; + - ``O``: organization e.g. ``Stanford National Laboratory``; + - ``OU``: organizational unit e.g. ``SLAC Certificate Authority``; + - ``C``: country e.g. ``US``. + + In Secure PVAccess: + + - the ``CN`` common name stores + - the device name e.g. ``KLYS:LI16:21``, + - or username e.g. ``greg``, + - or process name e.g. ``archiver``. + + For Certificate Authorities the ``CN`` field will be + - the name of the CA, e.g. ``SLAC Certificate Authority`` or ``ORNL CA``. + This field value is used in an ASG AUTHORITY rule to identify the certificate issuer. + + - the ``O`` organization field stores + - the hostname e.g. ``centos01``, + - the IP Address e.g. ``192.168.3.2``, + - the realm e.g. ``SLAC.STANFORD.EDU``, + - or another domain identifier. + + - the ``OU`` organizational unit field stores + - is optional but can be used to store the organizational unit e.g. ``PEP II``, or ``LCLS``. + + - the ``C`` country field stores + - the country e.g. ``US`` + +.. _glossary_client_certificate: + +- Client Certificate, Server Certificate, X.509. + In cryptography, a client certificate is a type of digital certificate that is used by client systems to make authenticated requests to a remote server which itself has a server certificate. + They contain claims that are signed by a CA that is trusted by the peer certificate user. + All Secure PVAccess certificates are X.509 certificates. + +.. _glossary_custom_extension: + +- Custom Extension, for X.509 Certificates. + The `X.509` certificate format allows for the inclusion of custom extensions, (RFC 5208), + which are data blobs encoded within certificates and signed alongside other certificate claims. + In Secure PVAccess, we use a custom extension ``status_monitoring_extension``. + If present, the extension mandates that a certificate shall only be considered valid only if + its status is successfully verified retrieved from the PV provided within the extension and that the certificate status received is ``VALID``. + +.. _glossary_diskless_server: +.. _glossary_diskless_node: +.. _glossary_network_computer: +.. _glossary_hybrid_client: + +- Diskless Server, Diskless Node, Network Computer, Hybrid Client. + A network device without disk drives, which employs network booting to load its operating system from a server, and network mounted drives for storage. + +.. _glossary_epics_agents: + +- EPICS Agents. + Refers to any EPICS client, server, gateway, or tool. + +.. _glossary_epics_security: + +- EPICS Security. + The EPICS technology that provides user Authorization. It is configured using an :ref:`access_control_file_ACF`. + +.. _glossary_jwt: + +- JWT - JSON Web Token. + (RFC 7519) - A compact URL-safe means of representing claims to be transferred between two parties. + The token is signed to certify its authenticity. + It will generally contain a claim as to the identity of the bearer (sub) as well as validity date ranges (nbf, exp). + + +.. _glossary_kerberos: +.. _glossary_kerberos_ticket: + +- Kerberos, Kerberos Ticket. + A protocol for authenticating service requests between trusted hosts across an untrusted network, such as the internet. + Kerberos support is built into all major computer operating systems, including Microsoft Windows, Apple macOS, FreeBSD and Linux. + A Kerberos ticket is a certificate issued by an authentication server (Key Distribution Center - KDC) and encrypted using that server’s key. + Two ticket types: A Ticket Granting Ticket (TGT) allows clients to subsequently request Service Tickets which are then passed to servers as the client’s credentials. + An important distinction with Kerberos is that it uses a symmetric key system where the same key used to encode data is used to decode it therefore that key is never shared and so only the KDC can verify a Kerberos ticket that it has issued – clients or servers can’t independently verify that a ticket is valid. + An EPICS agent needing to get a certificate will need to contact PVACMS using GSSAPI to be authenticated. + +.. _glossary_ocsp: + +- OCSP - Online Certificate Status Protocol. + A modern alternative to the Certificate Revocation List (CRL) that is used to check whether a digital certificate is valid or has been revoked. + While OCSP requests and responses are typically served over HTTP, we use PVACS to request, and receive, OCSP responses over the Secure PVAccess Protocol. + +.. _glossary_pkcs12: + +- PKCS#12 - Public Key Cryptography Standard. + In cryptography, PKCS#12 defines an archive file format for storing many cryptography objects as a single file. + It is commonly used to bundle a private key with its X.509 certificate and/or to bundle all the members of a chain of trust. + It is defined in ``RFC 7292``. + We use PKCS#12 files to store the EPICS agent's public / private key pair, and recommend using a separate PKCS#12 file for each EPICS agent certificate created using the public key. + The PKCS#12 files are referenced by environment variables described in the :ref:`secure_pvaccess_configuration`. + +.. _glossary_pvacms_stapling: + +- PVACS Stapling. + This is the equivalent of OCSP stapling but implemented using PVACS. + +.. _glossary_skid: + +- SKID - Subject Key Identifier. + + - The SKID identifies the subject of the certificate. + In simple terms the subject key identifier of a certificate is nothing more than a mechanism for certifying + that the bearer of the certificate has the private corresponding to the certificate's public key. + - so, the SKID is a way of identifying the private key so that if it is used to generate a new certificate + the bearer is identified as the same. Its saying “This is my X” where X can be + a process, machine, IOC, service, or anything that can participate in a Secure + EPICS network. + - In practice it simply makes a hash of the public key, + as the public key has a one-to-one relationship to the private key. + - An EPICS agent keeps the private key in a separate key file to + the certificate so that it can be used to generate a new certificate when + the old one expires and will retain the same SKID on the network. You can’t + generate a new certificate with the same SKID while a prior one has not ``EXPIRED`` or been ``REVOKED``. + - when we show the SKID of a certificate issuer we use only the first 8 characters of the hexadecimal hash. + diff --git a/documentation/spva_client_connection_state_machines.png b/documentation/spva_client_connection_state_machines.png new file mode 100644 index 000000000..82f1da44c Binary files /dev/null and b/documentation/spva_client_connection_state_machines.png differ diff --git a/documentation/spva_connection_state_machines.png b/documentation/spva_connection_state_machines.png new file mode 100644 index 000000000..7b069ef50 Binary files /dev/null and b/documentation/spva_connection_state_machines.png differ diff --git a/documentation/spva_tls_client_context_search_states.png b/documentation/spva_tls_client_context_search_states.png new file mode 100644 index 000000000..7f53e26e6 Binary files /dev/null and b/documentation/spva_tls_client_context_search_states.png differ diff --git a/documentation/spva_tls_client_context_state_machine.png b/documentation/spva_tls_client_context_state_machine.png new file mode 100644 index 000000000..4c357e61f Binary files /dev/null and b/documentation/spva_tls_client_context_state_machine.png differ diff --git a/documentation/spva_tls_context_search_states.png b/documentation/spva_tls_context_search_states.png new file mode 100644 index 000000000..73f5bb660 Binary files /dev/null and b/documentation/spva_tls_context_search_states.png differ diff --git a/documentation/spva_tls_context_state_machine.png b/documentation/spva_tls_context_state_machine.png new file mode 100644 index 000000000..c9feae776 Binary files /dev/null and b/documentation/spva_tls_context_state_machine.png differ diff --git a/documentation/spvaident.png b/documentation/spvaident.png new file mode 100644 index 000000000..3e8b1808c Binary files /dev/null and b/documentation/spvaident.png differ diff --git a/documentation/spvaseqdiag.png b/documentation/spvaseqdiag.png new file mode 100644 index 000000000..78e5b9479 Binary files /dev/null and b/documentation/spvaseqdiag.png differ diff --git a/example/Makefile b/example/Makefile index 44782327e..48653eaed 100644 --- a/example/Makefile +++ b/example/Makefile @@ -3,13 +3,13 @@ TOP=.. include $(TOP)/configure/CONFIG # cfg/ sometimes isn't correctly included due to a Base bug # so we do here (maybe again) as workaround -include $(TOP)/configure/CONFIG_PVXS_MODULE -include $(TOP)/configure/CONFIG_PVXS_VERSION +-include $(wildcard $(TOP)/cfg/CONFIG*) #---------------------------------------- # ADD MACRO DEFINITIONS AFTER THIS LINE #============================= PROD_LIBS += pvxs Com +USR_CPPFLAGS += -I$(TOP)/src TESTPROD_HOST += simplesrv simplesrv_SRCS += simplesrv.cpp @@ -35,7 +35,10 @@ rpc_client_SRCS += rpc_client.cpp #=========================== include $(TOP)/configure/RULES -include $(TOP)/configure/RULES_PVXS_MODULE +-include $(wildcard $(TOP)/cfg/RULES*) #---------------------------------------- # ADD RULES AFTER THIS LINE +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL +endif diff --git a/example/simpleget.cpp b/example/simpleget.cpp index 19186e989..f493b1fe1 100644 --- a/example/simpleget.cpp +++ b/example/simpleget.cpp @@ -7,6 +7,7 @@ */ #include +#include #include #include @@ -22,10 +23,28 @@ int main(int argc, char* argv[]) { // fetch PV "some:pv:name" and wait up to 5 seconds for a reply. // (throws an exception on error, including timeout) - Value reply = ctxt.get("some:pv:name").exec()->wait(5.0); - - // Reply is printed to stdout. - std::cout<wait(5.0); + std::cout << reply << std::endl; + if (argc == 3) { + while (true) { + auto end = std::chrono::high_resolution_clock::now(); + auto remaining_time = std::chrono::seconds(3) - (end - start); + if (remaining_time.count() > 0) { + std::this_thread::sleep_for(remaining_time); + } + start = std::chrono::high_resolution_clock::now(); + reply = ctxt.get(pv_name).exec()->wait(5.0); + + // Reply is printed to stdout. + std::cout << reply << std::endl; + } + } return 0; } diff --git a/ioc/Makefile b/ioc/Makefile index 8366e9c1e..a3422bd8a 100644 --- a/ioc/Makefile +++ b/ioc/Makefile @@ -11,12 +11,15 @@ TOP=.. include $(TOP)/configure/CONFIG # cfg/ sometimes isn't correctly included due to an issue in epics-base # so we do here (maybe again) as workaround -include $(TOP)/configure/CONFIG_PVXS_MODULE -include $(TOP)/configure/CONFIG_PVXS_VERSION +-include $(wildcard $(TOP)/cfg/CONFIG*) #---------------------------------------- # ADD MACRO DEFINITIONS AFTER THIS LINE #============================= +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL +endif + # access to private headers USR_CPPFLAGS += -I$(TOP)/src USR_CPPFLAGS += -DPVXS_IOC_API_BUILDING @@ -69,7 +72,8 @@ pvxsIoc_SRCS += dummygroup.cpp endif # BASE_7_0 -pvxsIoc_LIBS += $(EPICS_BASE_IOC_LIBS) +# LIB_LIB already includes EPICS_BASE_IOC_LIBS: unnecessary here +#pvxsIoc_LIBS += $(EPICS_BASE_IOC_LIBS) else # BASE_3_15 @@ -85,7 +89,7 @@ LIB_LIBS += $(EPICS_BASE_IOC_LIBS) #=========================== include $(TOP)/configure/RULES -include $(TOP)/configure/RULES_PVXS_MODULE +-include $(wildcard $(TOP)/cfg/RULES*) #---------------------------------------- # ADD RULES AFTER THIS LINE diff --git a/ioc/credentials.cpp b/ioc/credentials.cpp index 3427b061c..3ebf98981 100644 --- a/ioc/credentials.cpp +++ b/ioc/credentials.cpp @@ -27,18 +27,9 @@ Credentials::Credentials(const server::ClientCredentials& clientCredentials) { // Extract host name part (or whole thing if no colon present) auto pos = clientCredentials.peer.find_first_of(':'); host = clientCredentials.peer.substr(0, pos); - - // "ca" style credentials - if (clientCredentials.method == "ca") { - pos = clientCredentials.account.find_last_of('/'); - if (pos == std::string::npos) { - cred.emplace_back(clientCredentials.account); - } else { - cred.emplace_back(clientCredentials.account.substr(pos + 1)); - } - } else { - cred.emplace_back(SB() << clientCredentials.method << '/' << clientCredentials.account); - } + method = clientCredentials.method; + authority = clientCredentials.authority; + cred.emplace_back(clientCredentials.account); for (const auto& role: clientCredentials.roles()) { cred.emplace_back(SB() << "role/" << role); diff --git a/ioc/credentials.h b/ioc/credentials.h index 00a9eabda..6a4846e13 100644 --- a/ioc/credentials.h +++ b/ioc/credentials.h @@ -27,6 +27,8 @@ namespace ioc { class Credentials { public: std::vector cred; + std::string method; + std::string authority; std::string host; explicit Credentials(const server::ClientCredentials& clientCredentials); Credentials(const Credentials&) = delete; diff --git a/ioc/iochooks.cpp b/ioc/iochooks.cpp index fe50e6d77..0c6b6ad2f 100644 --- a/ioc/iochooks.cpp +++ b/ioc/iochooks.cpp @@ -303,6 +303,21 @@ void pvxrefdiff() { } } +#ifdef PVXS_ENABLE_OPENSSL +void pvxreconfigure() +{ + Guard (pvxServer->lock); + auto& srv = pvxServer->srv; + + if (srv) { + printf("Reconfiguring QSRV\n"); + srv.reconfigure(server::Config::from_env()); + pvxsr(0); // print new configuration + } else { + fprintf(stderr, "Warning: QSRV not running\n"); + } +} +#endif } // namespace static @@ -468,6 +483,11 @@ void pvxsBaseRegistrar() noexcept { "Save the current set of instance counters for reference by later pvxrefdiff.\n").implementation<&pvxrefsave>(); IOCShCommand<>("pvxrefdiff", "Show different of current instance counts with those when pvxrefsave was called.\n").implementation<&pvxrefdiff>(); +#ifdef PVXS_ENABLE_OPENSSL + IOCShCommand<>("pvxreconfigure", + "Reconfigure QSRV using current values of EPICS_PVA*. Only disconnects TLS clients\n") + .implementation<&pvxreconfigure>(); +#endif // Initialise the PVXS Server initialisePvxsServer(); diff --git a/ioc/securityclient.cpp b/ioc/securityclient.cpp index eb21c7b45..b089f3bb1 100644 --- a/ioc/securityclient.cpp +++ b/ioc/securityclient.cpp @@ -16,23 +16,31 @@ namespace pvxs { namespace ioc { -void SecurityClient::update(dbChannel* ch, Credentials& cred) { +void SecurityClient::update(ASMEMBERPVT mem, int asl, Credentials& cred) { SecurityClient temp; temp.cli.resize(cred.cred.size(), nullptr); for (size_t i = 0, N = temp.cli.size(); i < N; i++) { /* asAddClient() fails secure to no-permission */ - (void)asAddClient(&temp.cli[i], - dbChannelRecord(ch)->asp, - dbChannelFldDes(ch)->as_level, - cred.cred[i].c_str(), - // TODO switch to vector of char to accommodate inplace modifications to string - const_cast(cred.host.data())); + (void)asAddClientX(&temp.cli[i], + mem, + asl, + cred.cred[i].c_str(), + // TODO switch to vector of char to accommodate inplace modifications to string + const_cast(cred.method.c_str()), + const_cast(cred.authority.c_str()), + const_cast(cred.host.data()), + true // isTLS TODO fix this!!! + ); } cli.swap(temp.cli); } +void SecurityClient::update(dbChannel* ch, Credentials& cred) { + update(dbChannelRecord(ch)->asp, dbChannelFldDes(ch)->as_level, cred); +} + SecurityClient::~SecurityClient() { for (auto asc: cli) { asRemoveClient(&asc); @@ -40,16 +48,9 @@ SecurityClient::~SecurityClient() { } bool SecurityClient::canWrite() const { - return std::all_of(cli.begin(), cli.end(), [](ASCLIENTPVT asc) { + return std::any_of(cli.begin(), cli.end(), [](ASCLIENTPVT asc) { return asCheckPut(asc); }); } - -PutOperationCache::~PutOperationCache() { - // To avoid bug epics-base: unchecked access to notify.chan - if (notify.chan) { - dbNotifyCancel(¬ify); - } -} } // pvxs } // ioc diff --git a/ioc/securityclient.h b/ioc/securityclient.h index 7288fdfa8..e2899fc66 100644 --- a/ioc/securityclient.h +++ b/ioc/securityclient.h @@ -27,6 +27,7 @@ class SecurityClient { std::vector cli; ~SecurityClient(); void update(dbChannel* ch, Credentials& cred); + void update(ASMEMBERPVT mem, int asl, Credentials& cred); bool canWrite() const; }; @@ -68,7 +69,12 @@ struct PutOperationCache : public SingleSecurityCache { Value valueToSet; std::unique_ptr putOperation; INST_COUNTER(PutOperationCache); - ~PutOperationCache(); + ~PutOperationCache() { + // To avoid bug epics-base: unchecked access to notify.chan + if (notify.chan) { + dbNotifyCancel(¬ify); + } + } }; } // pvxs diff --git a/ioc/securitylogger.h b/ioc/securitylogger.h index f432ae41f..d80028cf9 100644 --- a/ioc/securitylogger.h +++ b/ioc/securitylogger.h @@ -45,9 +45,12 @@ class SecurityLogger { const Credentials& credentials, const SecurityClient& securityClient) :pfieldsave(pDbChannel->addr.pfield) - ,pvt(asTrapWriteWithData((securityClient.cli)[0], // The user is the first element + ,pvt(asTrapWriteWithDataX((securityClient.cli)[0], // The user is the first element credentials.cred[0].c_str(), // The user is the first element + credentials.method.c_str(), + credentials.authority.c_str(), credentials.host.c_str(), + true, // isTLS TODO fix this!! pDbChannel, dbChannelFinalFieldType(pDbChannel), dbChannelFinalElements(pDbChannel), diff --git a/qsrv/Makefile b/qsrv/Makefile index 6faf92743..94a4290e7 100644 --- a/qsrv/Makefile +++ b/qsrv/Makefile @@ -3,8 +3,7 @@ TOP=.. include $(TOP)/configure/CONFIG # cfg/ sometimes isn't correctly included due to a Base bug # so we do here (maybe again) as workaround -include $(TOP)/configure/CONFIG_PVXS_MODULE -include $(TOP)/configure/CONFIG_PVXS_VERSION +-include $(wildcard $(TOP)/cfg/CONFIG*) #---------------------------------------- # ADD MACRO DEFINITIONS AFTER THIS LINE #============================= @@ -31,10 +30,14 @@ FINAL_LOCATION ?= $(shell $(PERL) $(TOOLS)/fullPathName.pl $(INSTALL_LOCATION)) #=========================== include $(TOP)/configure/RULES -include $(TOP)/configure/RULES_PVXS_MODULE +-include $(wildcard $(TOP)/cfg/RULES*) #---------------------------------------- # ADD RULES AFTER THIS LINE +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL +endif + softMain$(DEP): epicsInstallDir.h epicsInstallDir.h: $(TOP)/configure/CONFIG_SITE* diff --git a/setup.py b/setup.py index e206878ad..35c33cfcf 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ def eventversion(): def cexpand(iname, oname, defs={}, dry_run=False): """Expand input file to output file. - + defs dict used to expand "@MACRO@", or replace "#cmakedefine MACRO VALUE" lines """ log.info('expand %s -> %s', iname, oname) @@ -138,14 +138,21 @@ def run(self): 'EVENT__HAVE_MBEDTLS':None, } + probe = ProbeToolchain() + + with open('configure/probe-openssl.c', 'r') as F: + if probe.try_compile(F.read()): + DEFS['EVENT__HAVE_OPENSSL'] = '1' + log.info('Enable OpenSSL Support') + else: + log.info('No OpenSSL Support') + DEFS.update(pvxsversion) # PVXS_*_VERSION DEFS.update(eventversion) # EVENT*VERSION for var in ('EPICS_HOST_ARCH', 'T_A', 'OS_CLASS', 'CMPLR_CLASS'): DEFS[var] = get_config_var(var) - probe = ProbeToolchain() - if probe.check_symbol('__GNU_LIBRARY__', headers=['features.h']): DEFS['_GNU_SOURCE'] = '1' probe.define_macros += [('_GNU_SOURCE', None)] @@ -240,6 +247,7 @@ def run(self): 'mmap64', 'pipe', 'pipe2', + 'pread', 'poll', 'port_create', 'sendfile', @@ -521,6 +529,11 @@ def define_DSOS(self): elif DEFS['EVENT__HAVE_SELECT']=='1': src_core += ['select.c'] + pvxs_tls_macros = [] + if DEFS['EVENT__HAVE_OPENSSL']=='1': + src_core += ['bufferevent_openssl.c', 'bufferevent_ssl.c'] + pvxs_tls_macros += [('PVXS_ENABLE_OPENSSL', None)] + src_core = [os.path.join('bundle', 'libevent', src) for src in src_core] src_pvxs = [ @@ -564,11 +577,18 @@ def define_DSOS(self): src_pvxs += ['src/os/WIN32/osdSockExt.cpp'] else: src_pvxs += ['src/os/default/osdSockExt.cpp'] + if DEFS['EVENT__HAVE_OPENSSL']=='1': + src_pvxs += [ + 'src/openssl.cpp' + ] event_libs = [] if OS_CLASS=='WIN32': event_libs = ['ws2_32','shell32','advapi32','bcrypt','iphlpapi'] + if DEFS['EVENT__HAVE_OPENSSL']=='1': + event_libs += ['ssl', 'crypto'] + src_pvxsIoc = [ "ioc/channel.cpp", "ioc/credentials.cpp", @@ -642,7 +662,15 @@ def define_DSOS(self): libraries = event_libs, ), DSO('pvxslibs.lib.pvxs', src_pvxs, - define_macros = [('PVXS_API_BUILDING', None), ('PVXS_ENABLE_EXPERT_API', None)] + get_config_var('CPPFLAGS'), + define_macros = [ + ('PVXS_API_BUILDING', None), + ('PVXS_ENABLE_EXPERT_API', None), + ('PVXS_ENABLE_SSLKEYLOGFILE', None), + ('PVXS_ENABLE_OPENSSL', None), + ('PVXS_ENABLE_KRB_AUTH', None), + ('PVXS_ENABLE_JWT_AUTH', None), + ('PVXS_ENABLE_LDAP_AUTH', None), + ] + pvxs_tls_macros + get_config_var('CPPFLAGS'), include_dirs=[ 'bundle/libevent/include', 'src', diff --git a/setup/CONFIG_PVXS_MODULE b/setup/CONFIG_PVXS_MODULE new file mode 100644 index 000000000..c96f8b23d --- /dev/null +++ b/setup/CONFIG_PVXS_MODULE @@ -0,0 +1,40 @@ +# auto-compute location of this file. +# avoid need to standardize configure/RELEASE name +_PVXS := $(dir $(lastword $(MAKEFILE_LIST))) + +# we're appending so must be idempotent +ifeq (,$(_PVXS_CONF_INCLUDED)) +_PVXS_CONF_INCLUDED := YES + +ifdef T_A + +ifneq (YES,$(_PVXS_BOOTSTRAP)) +include $(_PVXS)/TOOLCHAIN_PVXS.$(T_A) +endif + +# from generated cfg/TOOLCHAIN_PVXS.$(T_A) +LIBEVENT_PREFIX = $(LIBEVENT_PREFIX_$(T_A)) +LIBEVENT_BUNDLE_LIBS = $(LIBEVENT_BUNDLE_LIBS_$(T_A)) +LIBEVENT_SYS_LIBS = $(LIBEVENT_SYS_LIBS_$(T_A)) + +# apply to include search paths +INCLUDES += $(if $(LIBEVENT_PREFIX),-I$(LIBEVENT_PREFIX)/include) + +LIBEVENT_BUNDLE_LDFLAGS__RPATH=-Wl,-rpath,$(LIBEVENT_PREFIX)/lib +LIBEVENT_BUNDLE_LDFLAGS_Darwin_NO = $(if $(LIBEVENT_PREFIX),$(LIBEVENT_BUNDLE_LDFLAGS__RPATH)) +LIBEVENT_BUNDLE_LDFLAGS += $(LIBEVENT_BUNDLE_LDFLAGS_$(OS_CLASS)_$(STATIC_BUILD)) + +event_core_DIR = $(LIBEVENT_PREFIX)/lib +event_openssl_DIR = $(LIBEVENT_PREFIX)/lib +event_pthreads_DIR = $(LIBEVENT_PREFIX)/lib + +OPENSSL_PREFIX = $(OPENSSL_PREFIX_$(T_A)) + +INCLUDES += $(if $(OPENSSL_PREFIX),-I$(OPENSSL_PREFIX)/include) +USR_LDFLAGS += $(if $(OPENSSL_PREFIX),-L$(OPENSSL_PREFIX)/lib) + +endif # T_A + +endif # _PVXS_CONF_INCLUDED + +# logic continues in RULES_PVXS_MODULE diff --git a/setup/Makefile b/setup/Makefile new file mode 100644 index 000000000..56514a4a9 --- /dev/null +++ b/setup/Makefile @@ -0,0 +1,51 @@ +TOP=.. + +# step 1 in configure/Makefile +# step 2. generate cfg/TOOLCHAIN_PVXS.$(T_A) +# install cfg/* +# remaining TOP directories will include generated files +_PVXS_BOOTSTRAP = YES + +include $(TOP)/configure/CONFIG + +LIBEVENT ?= $(LIBEVENT_$(T_A)) +LIBEVENT_$(T_A) ?= $(wildcard $(abspath $(TOP)/bundle/usr/$(T_A))) + +_LIBEVENT_BUNDLE_LIBS_YES = event_openssl +_LIBEVENT_SYS_LIBS_YES += ssl crypto + +_LIBEVENT_BUNDLE_LIBS += $(_LIBEVENT_BUNDLE_LIBS_$(EVENT2_HAS_OPENSSL)) +_LIBEVENT_BUNDLE_LIBS += event_core + +_LIBEVENT_SYS_LIBS += $(_LIBEVENT_SYS_LIBS_$(EVENT2_HAS_OPENSSL)) + +ifeq (WIN32,$(OS_CLASS)) +_LIBEVENT_SYS_LIBS += bcrypt iphlpapi netapi32 ws2_32 +else +_LIBEVENT_BUNDLE_LIBS += event_pthreads +endif + +# at this point we have included the generated O.$(T_A)/TOOLCHAIN +# and use this to generated CONFIG_PVXS_MODULE + +CFG += CONFIG_PVXS_MODULE +CFG += RULES_PVXS_MODULE + +ifdef T_A +CFG += TOOLCHAIN_PVXS.$(T_A) +endif + +include $(TOP)/configure/RULES + +ifdef T_A + +EXPAND_ARGS = -a $(T_A) -t "$(INSTALL_LOCATION)" +EXPAND_ARGS += "-DOPENSSL=$(OPENSSL)" +EXPAND_ARGS += "-DLIBEVENT=$(LIBEVENT)" +EXPAND_ARGS += "-DLIBEVENT_BUNDLE_LIBS=$(_LIBEVENT_BUNDLE_LIBS)" +EXPAND_ARGS += "-DLIBEVENT_SYS_LIBS=$(_LIBEVENT_SYS_LIBS)" + +TOOLCHAIN_PVXS.$(T_A): ../TOOLCHAIN_PVXS.target@ + $(EXPAND_TOOL) $(EXPAND_ARGS) $< $@ + +endif diff --git a/configure/RULES_PVXS_MODULE b/setup/RULES_PVXS_MODULE similarity index 97% rename from configure/RULES_PVXS_MODULE rename to setup/RULES_PVXS_MODULE index a37382857..0348f5c6b 100644 --- a/configure/RULES_PVXS_MODULE +++ b/setup/RULES_PVXS_MODULE @@ -11,7 +11,7 @@ endif _PVXS_CHECK_VARS := PROD TESTPROD LIB $(PROD) $(TESTPROD) $(LIBRARY) -ifeq (,$(LIBEVENT)) +ifeq (,$(LIBEVENT_PREFIX)) # libevent in default search path # $(1) is PROD or LIBRARY name diff --git a/setup/TOOLCHAIN_PVXS.target@ b/setup/TOOLCHAIN_PVXS.target@ new file mode 100644 index 000000000..f4172fef1 --- /dev/null +++ b/setup/TOOLCHAIN_PVXS.target@ @@ -0,0 +1,4 @@ +OPENSSL_PREFIX_@ARCH@ = @OPENSSL@ +LIBEVENT_PREFIX_@ARCH@ = @LIBEVENT@ +LIBEVENT_BUNDLE_LIBS_@ARCH@ = @LIBEVENT_BUNDLE_LIBS@ +LIBEVENT_SYS_LIBS_@ARCH@ = @LIBEVENT_SYS_LIBS@ diff --git a/src/Makefile b/src/Makefile index 4a885d52a..bab4c209a 100644 --- a/src/Makefile +++ b/src/Makefile @@ -3,18 +3,23 @@ TOP=.. include $(TOP)/configure/CONFIG # cfg/ sometimes isn't correctly included due to a Base bug # so we do here (maybe again) as workaround -include $(TOP)/configure/CONFIG_PVXS_MODULE -include $(TOP)/configure/CONFIG_PVXS_VERSION +-include $(wildcard $(TOP)/cfg/CONFIG*) #---------------------------------------- # ADD MACRO DEFINITIONS AFTER THIS LINE #============================= +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL +endif # EVENT2_HAS_OPENSSL USR_CPPFLAGS += -DPVXS_API_BUILDING USR_CPPFLAGS += -DPVXS_ENABLE_EXPERT_API +PVXS_ENABLE_SSLKEYLOGFILE_YES = -DPVXS_ENABLE_SSLKEYLOGFILE +USR_CPPFLAGS += $(PVXS_ENABLE_SSLKEYLOGFILE_$(PVXS_ENABLE_SSLKEYLOGFILE)) + ifdef T_A ifneq ($(CONFIG_LOADED),YES) -$(error Toolchain inspection failed $(MAKEFILE_LIST)) +$(warning Toolchain inspection failed $(MAKEFILE_LIST)) endif endif @@ -31,10 +36,6 @@ endif # breaks on older ncurses (circa RHEL6) not using the INPUT() trick to pull in libtinfo.so #USR_LDFLAGS_Linux += -Wl,--no-undefined -Wl,--no-allow-shlib-undefined -ifeq (,$(PVXS_MAJOR_VERSION)) -$(error PVXS_MAJOR_VERSION undefined, problem reading cfg/CONFIG_PVXS_VERSION) -endif - # see below for special case versionNum.h EXPAND += describe.h @@ -53,65 +54,77 @@ GENVERSIONMACRO = PVXS_VCS_VERSION SHRLIB_VERSION = $(PVXS_MAJOR_VERSION).$(PVXS_MINOR_VERSION) -INC += pvxs/version.h -INC += pvxs/versionNum.h -INC += pvxs/log.h -INC += pvxs/unittest.h -INC += pvxs/util.h -INC += pvxs/sharedArray.h +# Access to certs specific headers +USR_CPPFLAGS += -I$(TOP)/certs +SRC_DIRS += $(TOP)/certs + +INC += pvxs/client.h +INC += pvxs/config.h INC += pvxs/data.h -INC += pvxs/nt.h +INC += pvxs/log.h INC += pvxs/netcommon.h +INC += pvxs/nt.h INC += pvxs/server.h -INC += pvxs/srvcommon.h +INC += pvxs/sharedArray.h INC += pvxs/sharedpv.h +INC += pvxs/sharedwildcardpv.h INC += pvxs/source.h -INC += pvxs/client.h +INC += pvxs/srvcommon.h +INC += pvxs/unittest.h +INC += pvxs/util.h +INC += pvxs/version.h +INC += pvxs/versionNum.h +INC += p12filewatcher.h LIBRARY = pvxs -LIB_SRCS += describe.cpp -LIB_SRCS += log.cpp -LIB_SRCS += unittest.cpp -LIB_SRCS += util.cpp -LIB_SRCS += osgroups.cpp -LIB_SRCS += sharedarray.cpp LIB_SRCS += bitmask.cpp -LIB_SRCS += type.cpp +LIB_SRCS += certstatus.cpp +LIB_SRCS += certstatusmanager.cpp +LIB_SRCS += client.cpp +LIB_SRCS += clientconn.cpp +LIB_SRCS += clientdiscover.cpp +LIB_SRCS += clientget.cpp +LIB_SRCS += clientintrospect.cpp +LIB_SRCS += clientmon.cpp +LIB_SRCS += clientreq.cpp +LIB_SRCS += config.cpp +LIB_SRCS += conn.cpp LIB_SRCS += data.cpp -LIB_SRCS += datafmt.cpp -LIB_SRCS += pvrequest.cpp LIB_SRCS += dataencode.cpp -LIB_SRCS += nt.cpp +LIB_SRCS += datafmt.cpp +LIB_SRCS += describe.cpp LIB_SRCS += evhelper.cpp -LIB_SRCS += udp_collector.cpp - +LIB_SRCS += log.cpp +LIB_SRCS += nt.cpp +LIB_SRCS += openssl.cpp LIB_SRCS += osdSockExt.cpp - -LIB_SRCS += config.cpp -LIB_SRCS += conn.cpp - +LIB_SRCS += osgroups.cpp +LIB_SRCS += pvrequest.cpp LIB_SRCS += server.cpp -LIB_SRCS += serverconn.cpp LIB_SRCS += serverchan.cpp -LIB_SRCS += serverintrospect.cpp +LIB_SRCS += serverconn.cpp LIB_SRCS += serverget.cpp +LIB_SRCS += serverintrospect.cpp LIB_SRCS += servermon.cpp LIB_SRCS += serversource.cpp +LIB_SRCS += sharedarray.cpp LIB_SRCS += sharedpv.cpp +LIB_SRCS += sharedwildcardpv.cpp +LIB_SRCS += type.cpp +LIB_SRCS += udp_collector.cpp +LIB_SRCS += unittest.cpp +LIB_SRCS += util.cpp -LIB_SRCS += client.cpp -LIB_SRCS += clientreq.cpp -LIB_SRCS += clientconn.cpp -LIB_SRCS += clientintrospect.cpp -LIB_SRCS += clientget.cpp -LIB_SRCS += clientmon.cpp -LIB_SRCS += clientdiscover.cpp +LIB_SRCS += certfactory.cpp +LIB_SRCS += certfilefactory.cpp +LIB_SRCS += p12filefactory.cpp +LIB_SRCS += pemfilefactory.cpp LIB_LIBS += Com # special case matching configure/RULES_PVXS_MODULE -ifeq (,$(LIBEVENT)) +ifeq (,$(LIBEVENT_PREFIX)) LIB_SYS_LIBS += $(LIBEVENT_BUNDLE_LIBS) else LIB_LIBS += $(LIBEVENT_BUNDLE_LIBS) @@ -119,10 +132,14 @@ endif LIB_SYS_LIBS += $(LIBEVENT_SYS_LIBS) +#ifeq ($(EVENT2_HAS_OPENSSL),YES) +#include $(SECURITY)/Makefile +#endif + #=========================== include $(TOP)/configure/RULES -include $(TOP)/configure/RULES_PVXS_MODULE +-include $(wildcard $(TOP)/cfg/RULES*) #---------------------------------------- # ADD RULES AFTER THIS LINE diff --git a/src/certstatus.cpp b/src/certstatus.cpp new file mode 100644 index 000000000..8a1cfe4eb --- /dev/null +++ b/src/certstatus.cpp @@ -0,0 +1,68 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The certificate status functions + * + * certstatus.cpp + * + */ + +#include "certstatus.h" + +#include "certstatusmanager.h" + +namespace pvxs { +namespace certs { + +OCSPStatus::OCSPStatus(ocspcertstatus_t ocsp_status, const shared_array &ocsp_bytes, StatusDate status_date, StatusDate status_valid_until_time, + StatusDate revocation_time) + : ocsp_bytes(ocsp_bytes), + ocsp_status(ocsp_status), + status_date(status_date), + status_valid_until_date(status_valid_until_time), + revocation_date(revocation_time) {}; + +void OCSPStatus::init() { + if (ocsp_bytes.empty()) { + ocsp_status = (OCSPCertStatus)OCSP_CERTSTATUS_UNKNOWN; + status_date = time(nullptr); + } else { + auto parsed_status = CertStatusManager::parse(ocsp_bytes); + ocsp_status = std::move(parsed_status.ocsp_status); + status_date = std::move(parsed_status.status_date); + status_valid_until_date = std::move(parsed_status.status_valid_until_date); + revocation_date = std::move(parsed_status.revocation_date); + } +} + +PVACertificateStatus::operator CertificateStatus() const noexcept { + return (status == UNKNOWN) ? (CertificateStatus)UnknownCertificateStatus{} : CertifiedCertificateStatus{*this}; +} +OCSPStatus::operator CertificateStatus() const noexcept { + return (ocsp_status == OCSP_CERTSTATUS_UNKNOWN) ? (CertificateStatus)UnknownCertificateStatus{} : CertifiedCertificateStatus{*this}; +} +bool OCSPStatus::operator==(const CertificateStatus &rhs) const { + return this->ocsp_status == rhs.ocsp_status && this->status_date == rhs.status_date && this->status_valid_until_date == rhs.status_valid_until_date && + this->revocation_date == rhs.revocation_date; +} +bool OCSPStatus::operator==(const PVACertificateStatus &rhs) const { return (CertificateStatus) * this == rhs; } +bool PVACertificateStatus::operator==(const CertificateStatus &rhs) const { + return this->status == rhs.status && this->ocsp_status == rhs.ocsp_status && this->status_date == rhs.status_date && + this->status_valid_until_date == rhs.status_valid_until_date && this->revocation_date == rhs.revocation_date; +} + +bool operator==(ocspcertstatus_t &lhs, PVACertificateStatus &rhs) { return rhs == lhs; }; +bool operator!=(ocspcertstatus_t &lhs, PVACertificateStatus &rhs) { return rhs != lhs; }; +bool operator==(certstatus_t &lhs, PVACertificateStatus &rhs) { return rhs == lhs; }; +bool operator!=(certstatus_t &lhs, PVACertificateStatus &rhs) { return rhs != lhs; }; + +bool operator==(ocspcertstatus_t &lhs, OCSPStatus &rhs) { return rhs == lhs; }; +bool operator!=(ocspcertstatus_t &lhs, OCSPStatus &rhs) { return rhs != lhs; }; +bool operator==(certstatus_t &lhs, OCSPStatus &rhs) { return rhs == lhs; }; +bool operator!=(certstatus_t &lhs, OCSPStatus &rhs) { return rhs != lhs; }; + +} // namespace certs +} // namespace pvxs diff --git a/src/certstatus.h b/src/certstatus.h new file mode 100644 index 000000000..c9c1d9651 --- /dev/null +++ b/src/certstatus.h @@ -0,0 +1,831 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The certificate status functions + * + * certstatus.h + * + */ +#ifndef PVXS_CERTSTATUS_H_ +#define PVXS_CERTSTATUS_H_ + +#include + +#include + +#include +#include + +#include "ownedptr.h" + +#define CERT_TIME_FORMAT "%a %b %d %H:%M:%S %Y UTC" + +typedef epicsGuard Guard; +typedef epicsGuardRelease UnGuard; + +DEFINE_LOGGER(status_setup, "pvxs.certs.status"); + +namespace pvxs { +namespace certs { + +///////////// OCSP RESPONSE ERRORS +class OCSPParseException : public std::runtime_error { + public: + explicit OCSPParseException(const std::string& message) : std::runtime_error(message) {} +}; + +class CertStatusException : public std::runtime_error { + public: + explicit CertStatusException(const std::string& message) : std::runtime_error(message) {} +}; + +class CertStatusNoExtensionException : public CertStatusException { + public: + explicit CertStatusNoExtensionException(const std::string& message) : CertStatusException(message) {} +}; + +class CertStatusSubscriptionException : public CertStatusException { + public: + explicit CertStatusSubscriptionException(const std::string& message) : CertStatusException(message) {} +}; + +// Certificate management +#define GET_MONITOR_CERT_STATUS_ROOT "CERT:STATUS" +#define GET_MONITOR_CERT_STATUS_PV "CERT:STATUS:????????:*" + +// All certificate statuses +#define CERT_STATUS_LIST \ + X_IT(UNKNOWN) \ + X_IT(PENDING_APPROVAL) \ + X_IT(PENDING) \ + X_IT(VALID) \ + X_IT(EXPIRED) \ + X_IT(REVOKED) + +// All OCSP certificate statuses +#define OCSP_CERT_STATUS_LIST \ + O_IT(OCSP_CERTSTATUS_GOOD) \ + O_IT(OCSP_CERTSTATUS_REVOKED) \ + O_IT(OCSP_CERTSTATUS_UNKNOWN) + +// Define the enum +#define X_IT(name) name, +#define O_IT(name) name = V_##name, +enum certstatus_t { CERT_STATUS_LIST }; +enum ocspcertstatus_t { OCSP_CERT_STATUS_LIST }; +#undef X_IT +#undef O_IT + +// String initializer list +#define X_IT(name) #name, +#define O_IT(name) #name, +#define CERT_STATES {CERT_STATUS_LIST} +#define OCSP_CERT_STATES {OCSP_CERT_STATUS_LIST} + +// Gets status name based on index +#define CERT_STATE(index) ((const char*[])CERT_STATES[(index)]) +#define OCSP_CERT_STATE(index) ((const char*[])OCSP_CERT_STATES[(index)]) + +// Forward declarations +struct PVACertStatus; +struct OCSPCertStatus; + +/** + * @brief Base class for Certificate status values. Contains the enum index `i` + * and the string representation `s` of the value for logging and comparison + * + * @note This class is not intended to be instantiated directly. + * It is used as a base class for PVACertStatus and OCSPCertStatus + */ +struct CertStatus { + // enum value of the status + uint32_t i; + // string representation of the status + std::string s; + // Default constructor + CertStatus() = default; + + // Delete the comparison operators to prevent comparison of CertStatus directly + bool operator==(PVACertStatus rhs) = delete; + bool operator==(OCSPCertStatus rhs) = delete; + bool operator==(ocspcertstatus_t rhs) = delete; + bool operator==(certstatus_t rhs) = delete; + bool operator!=(PVACertStatus rhs) = delete; + bool operator!=(OCSPCertStatus rhs) = delete; + bool operator!=(ocspcertstatus_t rhs) = delete; + bool operator!=(certstatus_t rhs) = delete; + + /** + * @brief The prototype of the data returned for a certificate status request + * Essentially an enum, a serial number and the ocsp response + * + * @return The prototype of the data returned for a certificate status request + */ + static inline Value getStatusPrototype() { + using namespace members; + nt::NTEnum enum_value; + nt::NTEnum enum_ocspvalue; + + auto value = TypeDef(TypeCode::Struct, + { + enum_value.build().as("status"), + Member(TypeCode::UInt64, "serial"), + Member(TypeCode::String, "state"), + enum_ocspvalue.build().as("ocsp_status"), + Member(TypeCode::String, "ocsp_state"), + Member(TypeCode::String, "ocsp_status_date"), + Member(TypeCode::String, "ocsp_certified_until"), + Member(TypeCode::String, "ocsp_revocation_date"), + Member(TypeCode::UInt8A, "ocsp_response"), + }) + .create(); + shared_array choices(CERT_STATES); + value["status.value.choices"] = choices.freeze(); + shared_array ocsp_choices(OCSP_CERT_STATES); + value["ocsp_status.value.choices"] = ocsp_choices.freeze(); + return value; + } + + /** + * @brief Get the issuer ID which is the first 8 hex digits of the hex SKID (subject key identifier) + * + * Note that the given cert must contain the SKID extension in the first place + * + * @param ca_cert the cert from which to get the subject key identifier extension + * @return first 8 hex digits of the hex SKID (subject key identifier) + */ + static inline std::string getIssuerId(const ossl_ptr& ca_cert) { return getIssuerId(ca_cert.get()); } + + /** + * @brief Get the issuer ID which is the first 8 hex digits of the hex SKID (subject key identifier) + * + * Note that the given cert must contain the SKID extension in the first place + * + * @param ca_cert_ptr the cert pointer from which to get the subject key identifier extension + * @return first 8 hex digits of the hex SKID (subject key identifier) + */ + static inline std::string getIssuerId(X509* ca_cert_ptr) { + ossl_ptr skid(reinterpret_cast(X509_get_ext_d2i(ca_cert_ptr, NID_subject_key_identifier, nullptr, nullptr)), + false); + if (!skid) { + throw std::runtime_error("Failed to get Subject Key Identifier."); + } + + // Convert first 8 chars to hex + auto buf = const_cast(skid->data); + std::stringstream ss; + for (int i = 0; i < skid->length && ss.tellp() < 8; i++) { + ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(buf[i]); + } + + return ss.str(); + } + + /** + * @brief Get the common name of the given certificate + * return the common name or an empty string if cert is null or + * there are any problems retrieving the common name + * + * @param cert to retrieve the subject CN field + * @return the common name + */ + static inline std::string getCommonName(ossl_ptr& cert) { + if (!cert) return ""; + + // Get the subject name from the certificate + X509_NAME* subject = X509_get_subject_name(cert.get()); + if (!subject) { + return ""; + } + + // Find the position of the Common Name field within the subject name + int idx = X509_NAME_get_index_by_NID(subject, NID_commonName, -1); + if (idx < 0) { + return ""; + } + + X509_NAME_ENTRY* entry = X509_NAME_get_entry(subject, idx); + if (!entry) { + return ""; + } + + ASN1_STRING* data = X509_NAME_ENTRY_get_data(entry); + if (!data) { + return ""; + } + + // Convert the ASN1_STRING to a UTF-8 C string + unsigned char* utf8 = nullptr; + int length = ASN1_STRING_to_UTF8(&utf8, data); + if (length < 0 || !utf8) { + return ""; + } + + // Construct a std::string from the UTF-8 data + std::string cn(reinterpret_cast(utf8), length); + OPENSSL_free(utf8); + + return cn; + } + + /** + * @brief Make the status URI for a certificate + * + * @param issuer_id the issuer ID (first 8 hex digits of the hex SKID) + * @param serial the serial number + * @return the status URI + */ + static inline std::string makeStatusURI(std::string& issuer_id, uint64_t& serial) { + return SB() << GET_MONITOR_CERT_STATUS_ROOT << ":" << issuer_id << ":" << std::setw(16) << std::setfill('0') << serial; + } + + protected: + /** + * @brief Constructor for CertStatus only to be used by PVACertStatus and OCSPCertStatus + * + * @param status the enum index of the status + * @param status_string the string representation of the status + */ + explicit CertStatus(const uint32_t status, std::string status_string) : i(status), s(status_string) {} +}; + +/** + * @brief PVA Certificate status values enum and string + */ +struct PVACertStatus : CertStatus { + // Delete the default constructor to prevent instantiation of PVACertStatus without an explicit status + PVACertStatus() = delete; + + /** + * @brief Constructor for PVACertStatus + * + * @param status the enum index of the status + */ + explicit PVACertStatus(const certstatus_t& status) : CertStatus(status, toString(status)) {} + + // Define the comparison operators + bool operator==(PVACertStatus rhs) const { return this->i == rhs.i; } + bool operator==(certstatus_t rhs) const { return this->i == rhs; } + bool operator!=(PVACertStatus rhs) const { return this->i != rhs.i; } + bool operator!=(certstatus_t rhs) const { return this->i != rhs; } + + private: + /** + * @brief Convert the enum index to the string representation of the status. Internal use only + * + * @param status the enum index of the status + * @return the string representation of the status + */ + static inline std::string toString(const certstatus_t status) { return CERT_STATE(status); } +}; + +/** + * @brief OCSP Certificate status values enum and string + */ +struct OCSPCertStatus : CertStatus { + // Default constructor + OCSPCertStatus() = default; + + /** + * @brief Constructor for OCSPCertStatus + * + * @param status the enum index of the status + */ + explicit OCSPCertStatus(const ocspcertstatus_t& status) : CertStatus((uint32_t)status, toString(status)) {} + + // Define the comparison operators + bool operator==(OCSPCertStatus rhs) const { return this->i == rhs.i; } + bool operator==(ocspcertstatus_t rhs) const { return this->i == rhs; } + bool operator!=(OCSPCertStatus rhs) const { return this->i != rhs.i; } + bool operator!=(ocspcertstatus_t rhs) const { return this->i != rhs; } + + private: + /** + * @brief Convert the enum index to the string representation of the status. Internal use only + * + * @param status the enum index of the status + * @return the string representation of the status + */ + static inline std::string toString(const ocspcertstatus_t& status) { return OCSP_CERT_STATE(status); } +}; + +/** + * @brief To create and manipulate status dates. + * Status dates have a string representation `s` as well as a time_t representation `t` + */ +struct StatusDate { + // time_t representation of the status date + std::time_t t; + // string representation of the status date + std::string s; + + // Default constructor + StatusDate() = default; + + // Constructor from time_t + StatusDate(const std::time_t& time) : t(time), s(toString(time)) {} + // Constructor from ASN1_TIME* + StatusDate(const ASN1_TIME* time) : t(asn1TimeToTimeT(time)), s(toString(t)) {} + // Constructor from ossl_ptr + StatusDate(const ossl_ptr& time) : t(asn1TimeToTimeT(time.get())), s(toString(t)) {} + // Constructor from time string + StatusDate(const std::string& time_string) : t(toTimeT(time_string)), s(StatusDate(t).s) {} + + // Define the comparison operator + inline bool operator==(StatusDate rhs) const { return this->t == rhs.t; } + + // Define the conversion operators + inline operator const std::string&() const { return s; } + inline operator std::string() const { return s; } + inline operator const time_t&() const { return t; } + inline operator time_t() const { return t; } + inline operator ossl_ptr() const { return toAsn1_Time(); }; + + /** + * @brief Create an ASN1_TIME object from this StatusDate object + * @return and ASN1_TIME object corresponding this StatusDate object + */ + inline ossl_ptr toAsn1_Time() const { + ossl_ptr asn1(ASN1_TIME_new()); + ASN1_TIME_set(asn1.get(), t); + return asn1; + } + + /** + * @brief Create an ASN1_TIME object from a StatusDate object + * @return and ASN1_TIME object corresponding the given StatusDate object + */ + static inline ossl_ptr toAsn1_Time(StatusDate status_date) { return status_date.toAsn1_Time(); } + + /** + * @brief To get the time_t (unix time) from a ASN1_TIME* time pointer + * @param time ASN1_TIME* time pointer to convert + * @return a time_t (unix time) version + */ + static inline time_t asn1TimeToTimeT(const ASN1_TIME* time) { + std::tm t{}; + if (!time) return 0; + + if (ASN1_TIME_to_tm(time, &t) != 1) throw std::runtime_error("Failed to convert ASN1_TIME to tm structure"); + + return tmToTimeTUTC(t); + } + + private: + /** + * @brief To format a string representation of the given time_t + * @param time the time_t to format + * @return the string representation in local time + */ + static inline std::string toString(const std::time_t& time) { + char buffer[100]; + if (std::strftime(buffer, sizeof(buffer), CERT_TIME_FORMAT, std::gmtime(&time))) { + return std::string(buffer); + } else { + throw OCSPParseException("Failed to format status date"); + } + } + + /** + * @brief Convert the given string to a time_t value. + * + * The string is assumed to represent a time in the UTC timezone. The + * format of the string is defined by `CERT_TIME_FORMAT`. The string is parsed + * and the time_t extracted and returned. + * + * Any errors in format are signalled by raising OCSPParseExceptions as this function + * is called from OCSP parsing + * + * @param time_string + * @return + */ + static inline time_t toTimeT(std::string time_string) { + // Read the string and parse it into std::tm + if (time_string.empty()) return 0; + std::tm tm = {}; + std::istringstream ss(time_string); + ss >> std::get_time(&tm, CERT_TIME_FORMAT); + + // Check if parsing was successful + if (ss.fail()) { + throw OCSPParseException("Failed to parse date-time string."); + } + + // Convert std::tm to time_t + return tmToTimeTUTC(tm); + } + + /** + * @brief To get the time_t (unix time) from a std::tm structure + * @param tm std::tm structure to convert + * @return a time_t (unix time) version + */ + static inline time_t tmToTimeTUTC(const std::tm& tm) { + // For accurate time calculation the start day in a year of each month + static const int kMonthStartDays[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; + int year = 1900 + tm.tm_year; + + // Calculate days up to start of the current year + time_t days = (year - 1970) * 365 + (year - 1969) / 4 // Leap years + - (year - 1901) / 100 // Excluding non-leap centuries + + (year - 1601) / 400; // Including leap centuries + + // Calculate days up to the start of the current month within the current year + days += kMonthStartDays[tm.tm_mon]; + if (tm.tm_mon > 1 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0))) { + days += 1; // Add one day for leap years after February + } + + // Adjust with the current day in the month (tm_mday starts from 1) + days += tm.tm_mday - 1; + + // Incorporate hours, minutes, and seconds + return ((days * 24 + tm.tm_hour) * 60 + tm.tm_min) * 60 + tm.tm_sec; + } +}; + +/** + * @brief To store OCSP status value - parsed out of an OCSP response + * + * This struct is used to store the parsed OCSP status value. It is used + * to store the serial number, the OCSP status, the status date, the status + * valid-until date, and the revocation date. + */ +struct ParsedOCSPStatus { + // serial number of the certificate + const uint64_t serial; + // OCSP status of the certificate + const OCSPCertStatus ocsp_status; + // date of the OCSP certificate status + const StatusDate status_date; + // valid-until date of the OCSP certificate status + const StatusDate status_valid_until_date; + // revocation date of the certificate if it is revoked + const StatusDate revocation_date; + + /** + * @brief Constructor for ParsedOCSPStatus + * + * @param serial the serial number of the certificate + * @param ocsp_status the OCSP status of the certificate + * @param status_date the status date of the certificate + * @param status_valid_until_date the status valid-until date of the certificate + * @param revocation_date the revocation date of the certificate if it is revoked + */ + ParsedOCSPStatus(const uint64_t& serial, const OCSPCertStatus& ocsp_status, const StatusDate& status_date, const StatusDate& status_valid_until_date, + const StatusDate& revocation_date) + : serial(serial), + ocsp_status(ocsp_status), + status_date(status_date), + status_valid_until_date(status_valid_until_date), + revocation_date(revocation_date) {} +}; + +// Forward declarations of the certificate status structures +struct CertificateStatus; +struct CertifiedCertificateStatus; +struct UnknownCertificateStatus; +struct UnCertifiedCertificateStatus; +struct PVACertificateStatus; + +/** + * @brief Structure representing OCSP status. + * + * It contains the OCSP response bytes as well as the date the status was set and how + * long the status is valid for. If the status is revoked then there is also a + * revocation date. The ocsp_status field contains the OCSP status in numerical and text form. + */ +struct OCSPStatus { + // raw OCSP response bytes + shared_array ocsp_bytes; + // OCSP status of the certificate + OCSPCertStatus ocsp_status; + // date of the OCSP certificate status + StatusDate status_date; + // valid-until date of the OCSP certificate status + StatusDate status_valid_until_date; + // revocation date of the certificate if it is revoked + StatusDate revocation_date; + + explicit OCSPStatus(const shared_array& ocsp_bytes_param) : ocsp_bytes(ocsp_bytes_param) { init(); } + + // To set an OCSP UNKNOWN status to indicate errors + OCSPStatus() : ocsp_status(OCSP_CERTSTATUS_UNKNOWN) {}; + + virtual inline bool operator==(const OCSPStatus& rhs) const { + return this->ocsp_status == rhs.ocsp_status && this->status_date == rhs.status_date && this->status_valid_until_date == rhs.status_valid_until_date && + this->revocation_date == rhs.revocation_date; + } + virtual inline bool operator!=(const OCSPStatus& rhs) const { return !(*this == rhs); } + virtual bool operator==(const CertificateStatus& rhs) const; + virtual inline bool operator!=(const CertificateStatus& rhs) const { return !(*this == rhs); } + virtual bool operator==(const PVACertificateStatus& rhs) const; + virtual inline bool operator!=(const PVACertificateStatus& rhs) const { return !(*this == rhs); } + virtual inline bool operator==(ocspcertstatus_t& rhs) const { return this->ocsp_status == rhs; } + virtual inline bool operator!=(ocspcertstatus_t& rhs) const { return !(*this == rhs); } + virtual inline bool operator==(certstatus_t& rhs) const { + return ((rhs == VALID && this->ocsp_status == OCSP_CERTSTATUS_GOOD) || (rhs == REVOKED && this->ocsp_status == OCSP_CERTSTATUS_REVOKED)); + } + virtual inline bool operator!=(certstatus_t& rhs) const { return !(*this == rhs); } + + /** + * @brief Verify that the status validity dates are currently valid and the status is known + * @return true if the status is still valid + */ + inline bool isValid() noexcept { + auto now(std::time(nullptr)); + return status_valid_until_date.t > now; + } + + inline bool isGood() noexcept { return isValid() && ocsp_status == OCSP_CERTSTATUS_GOOD; } + virtual explicit operator CertificateStatus() const noexcept; + + private: + friend struct PVACertificateStatus; + explicit OCSPStatus(ocspcertstatus_t ocsp_status, const shared_array& ocsp_bytes, StatusDate status_date, StatusDate status_valid_until_time, + StatusDate revocation_time); + + void init(); +}; + +bool operator==(ocspcertstatus_t& lhs, OCSPStatus& rhs); +bool operator!=(ocspcertstatus_t& lhs, OCSPStatus& rhs); +bool operator==(certstatus_t& lhs, OCSPStatus& rhs); +bool operator!=(certstatus_t& lhs, OCSPStatus& rhs); + +/** + * @brief Structure representing PVA-OCSP certificate status. This is a superclass of OCSPStatus + * + * It contains the OCSP response bytes as well as the date the status was set and how + * long the status is valid for. If the status is revoked then there is also a + * revocation date. The status field contains the PVA certificate status in numerical and text form. + * The ocsp_status field contains the OCSP status in numerical and text form. + */ +struct PVACertificateStatus final : public OCSPStatus { + PVACertStatus status; + inline bool operator==(const PVACertificateStatus& rhs) const { + return this->status == rhs.status && this->ocsp_status == rhs.ocsp_status && this->status_date == rhs.status_date && + this->status_valid_until_date == rhs.status_valid_until_date && this->revocation_date == rhs.revocation_date; + } + inline bool operator!=(const PVACertificateStatus& rhs) const { return !(*this == rhs); } + + inline bool operator==(certstatus_t& rhs) const { return this->status == rhs; } + inline bool operator!=(certstatus_t& rhs) const { return !(*this == rhs); } + inline bool operator==(ocspcertstatus_t& rhs) const { return this->ocsp_status == rhs; } + inline bool operator!=(ocspcertstatus_t& rhs) const { return !(*this == rhs); } + + inline bool operator==(const OCSPStatus& rhs) const { + return (this->status != VALID && this->status != REVOKED) + ? false + : this->ocsp_status == rhs.ocsp_status && this->status_date == rhs.status_date && + this->status_valid_until_date == rhs.status_valid_until_date && this->revocation_date == rhs.revocation_date; + } + inline bool operator!=(const OCSPStatus& rhs) const { return !(*this == rhs); } + bool operator==(const CertificateStatus& rhs) const; + inline bool operator!=(const CertificateStatus& rhs) const { return !(*this == rhs); } + + explicit PVACertificateStatus(const certstatus_t status, const shared_array& ocsp_bytes) : OCSPStatus(ocsp_bytes), status(status) {}; + + explicit PVACertificateStatus(const Value& status_value) + : PVACertificateStatus(status_value["status.value.index"].as(), status_value["ocsp_response"].as>()) { + if (ocsp_bytes.empty()) return; + log_debug_println(status_setup, "Value Status: %s\n", (SB() << status_value).str().c_str()); + log_debug_printf(status_setup, "Status Date: %s\n", this->status_date.s.c_str()); + log_debug_printf(status_setup, "Status Validity: %s\n", this->status_valid_until_date.s.c_str()); + log_debug_printf(status_setup, "Revocation Date: %s\n", this->revocation_date.s.c_str()); + if (!selfConsistent() || !dateConsistent(status_value["ocsp_status_date"].as(), status_value["ocsp_certified_until"].as(), + status_value["ocsp_revocation_date"].as())) { + throw OCSPParseException("Certificate status does not match certified OCSP status"); + }; + } + + // To set an UNKNOWN status to indicate errors + PVACertificateStatus() : OCSPStatus(), status(UNKNOWN) {} + operator CertificateStatus() const noexcept; + + private: + friend class CertStatusFactory; + /** + * @brief Constructor for PVACertificateStatus + * @param status PVA certificate status + * @param ocsp_status OCSP certificate status + * @param ocsp_bytes OCSP response bytes + * @param status_date Status date + * @param status_valid_until_time Status valid-until date + * @param revocation_time Revocation date + */ + explicit PVACertificateStatus(certstatus_t status, ocspcertstatus_t ocsp_status, const shared_array& ocsp_bytes, StatusDate status_date, + StatusDate status_valid_until_time, StatusDate revocation_time) + : OCSPStatus(ocsp_status, ocsp_bytes, status_date, status_valid_until_time, revocation_time), status(status) {}; + + /** + * @brief Check if the PVACertificateStatus is self-consistent, + * i.e. the OCSP status values are consistent with the PVA status values + * @return true if the PVACertificateStatus is self-consistent, false otherwise + */ + inline bool selfConsistent() { + return (ocsp_status == OCSP_CERTSTATUS_UNKNOWN && (!(status == VALID || status == REVOKED))) || + (ocsp_status == OCSP_CERTSTATUS_REVOKED && (status == REVOKED)) || (ocsp_status == OCSP_CERTSTATUS_GOOD && (status == VALID)); + } + + /** + * @brief Check if the PVACertificateStatus is date-consistent, + * i.e. the status date, status valid-until date, and revocation date are all the same + * @param status_date_value Status date + * @param status_valid_until_date_value Status valid-until date + * @param revocation_date_value Revocation date + * @return true if the PVACertificateStatus is date-consistent, false otherwise + */ + inline bool dateConsistent(StatusDate status_date_value, StatusDate status_valid_until_date_value, StatusDate revocation_date_value) { + return (status_date == status_date_value) && (status_valid_until_date == status_valid_until_date_value) && (revocation_date == revocation_date_value); + } +}; + +/** + * @brief Equality operator for ocspcertstatus_t and PVACertificateStatus + * @param lhs ocspcertstatus_t value to compare with + * @param rhs PVACertificateStatus object to compare with + * @return true if the ocspcertstatus_t value is equal to the ocsp_status of the PVACertificateStatus object, false otherwise + */ +bool operator==(ocspcertstatus_t& lhs, PVACertificateStatus& rhs); +/** + * @brief Inequality operator for ocspcertstatus_t and PVACertificateStatus + * @param lhs ocspcertstatus_t value to compare with + * @param rhs PVACertificateStatus object to compare with + * @return true if the ocspcertstatus_t value is not equal to the ocsp_status of the PVACertificateStatus object, false otherwise + */ +bool operator==(ocspcertstatus_t& lhs, PVACertificateStatus& rhs); +/** + * @brief Inequality operator for ocspcertstatus_t and PVACertificateStatus + * @param lhs ocspcertstatus_t value to compare with + * @param rhs PVACertificateStatus object to compare with + * @return true if the ocspcertstatus_t value is not equal to the ocsp_status of the PVACertificateStatus object, false otherwise + */ +bool operator!=(ocspcertstatus_t& lhs, PVACertificateStatus& rhs); +/** + * @brief Equality operator for certstatus_t and PVACertificateStatus + * @param lhs certstatus_t value to compare with + * @param rhs PVACertificateStatus object to compare with + * @return true if the certstatus_t value is equal to the status of the PVACertificateStatus object, false otherwise + */ +bool operator!=(certstatus_t& lhs, PVACertificateStatus& rhs); + +/** + * @brief Structure representing certificate status. + * + * This struct is used to store the certificate status. It contains the PVA certificate status, + * the OCSP status, the status date, the status valid-until date, and the revocation date. + */ +struct CertificateStatus { + // true if the status is certified + const bool certified; + // PVA certificate status + const PVACertStatus status; + // OCSP certificate status + const OCSPCertStatus ocsp_status; + // status date + const StatusDate status_date; + // status valid-until date + const StatusDate status_valid_until_date; + // certificate revocation date if the certificate is revoked + const StatusDate revocation_date; + + CertificateStatus() = delete; + ~CertificateStatus() = default; + + /** + * @brief Constructor for CertificateStatus + * @param cs PVACertificateStatus object to initialize the CertificateStatus object + */ + explicit CertificateStatus(PVACertificateStatus cs) + : CertificateStatus(true, cs.status, cs.ocsp_status, cs.status_date, cs.status_valid_until_date, cs.revocation_date) {}; + + /** + * @brief Equality operator for CertificateStatus + * @param rhs CertificateStatus object to compare with + * @return true if the CertificateStatus objects are equal, false otherwise + */ + inline bool operator==(const CertificateStatus& rhs) const { + return this->certified == rhs.certified && this->status == rhs.status && this->ocsp_status == rhs.ocsp_status && this->status_date == rhs.status_date && + this->status_valid_until_date == rhs.status_valid_until_date && this->revocation_date == rhs.revocation_date; + } + + /** + * @brief Inequality operator for CertificateStatus + * @param rhs CertificateStatus object to compare with + * @return true if the CertificateStatus objects are not equal, false otherwise + */ + inline bool operator!=(const CertificateStatus& rhs) const { return !(*this == rhs); } + + /** + * @brief Equality operator for PVACertificateStatus + * @param rhs PVACertificateStatus object to compare with + * @return true if the PVACertificateStatus objects are equal, false otherwise + */ + inline bool operator==(const PVACertificateStatus& rhs) const { return (CertificateStatus)rhs == *this; } + + /** + * @brief Inequality operator for PVACertificateStatus + * @param rhs PVACertificateStatus object to compare with + * @return true if the PVACertificateStatus objects are not equal, false otherwise + */ + inline bool operator!=(const PVACertificateStatus& rhs) const { return !(*this == rhs); } + + /** + * @brief Equality operator for certstatus_t + * @param rhs certstatus_t value to compare with + * @return true if the certstatus_t value is equal to the status, false otherwise + */ + inline bool operator==(certstatus_t& rhs) const { return this->status == rhs; } + + /** + * @brief Inequality operator for certstatus_t + * @param rhs certstatus_t value to compare with + * @return true if the certstatus_t value is not equal to the status, false otherwise + */ + inline bool operator!=(certstatus_t& rhs) const { return !(*this == rhs); } + + /** + * @brief Equality operator for ocspcertstatus_t + * @param rhs ocspcertstatus_t value to compare with + * @return true if the ocspcertstatus_t value is equal to the ocsp_status, false otherwise + */ + inline bool operator==(ocspcertstatus_t& rhs) const { return this->ocsp_status == rhs; } + + /** + * @brief Inequality operator for ocspcertstatus_t + * @param rhs ocspcertstatus_t value to compare with + * @return true if the ocspcertstatus_t value is not equal to the ocsp_status, false otherwise + */ + inline bool operator!=(ocspcertstatus_t& rhs) const { return !(*this == rhs); } + + /** + * @brief Verify that the status is currently valid + * @return true if the status is still valid + */ + inline bool isValid() const noexcept { + auto now(std::time(nullptr)); + return status_valid_until_date.t > now; + } + + /** + * @brief Check if the certificate status is GOOD + * First checks if the status is still valid, then checks if the ocsp_status is GOOD + * @return true if the certificate status is GOOD, false otherwise + */ + inline bool isGood() const noexcept { return isValid() && ocsp_status == OCSP_CERTSTATUS_GOOD; } + + private: + friend struct CertifiedCertificateStatus; + friend struct UnknownCertificateStatus; + friend struct UnCertifiedCertificateStatus; + explicit CertificateStatus(bool certified, PVACertStatus status, OCSPCertStatus ocsp_status, StatusDate status_date, StatusDate status_valid_until_date, + StatusDate revocation_date) + : certified(certified), + status(status), + ocsp_status(ocsp_status), + status_date(status_date), + status_valid_until_date(status_valid_until_date), + revocation_date(revocation_date) {}; +}; + +/** + * @brief Represents the status of a certified certificate. + * + * This is the certificate status struct to use when you don't need to carry round the heavy PKCS#7 `ocsp_bytes` + * It is certified because it can only be created from a certified `CertificateStatus` struct. + * Create by casting a `CertificateStatus` or by passing one to the single argument constructor. + * + * The `CertifiedCertificateStatus` struct encapsulates various attributes related to the + * status of a certified certificate, including PVA certificate status, OCSP status, status date, + * status valid-until date, and revocation date. + */ +struct CertifiedCertificateStatus final : public CertificateStatus { + explicit CertifiedCertificateStatus(PVACertificateStatus cs) + : CertificateStatus(true, cs.status, cs.ocsp_status, cs.status_date, cs.status_valid_until_date, cs.revocation_date) {}; + + private: + friend struct OCSPStatus; + explicit CertifiedCertificateStatus(OCSPStatus cs) + : CertificateStatus(true, PVACertStatus(cs.ocsp_status == OCSP_CERTSTATUS_GOOD ? VALID : REVOKED), cs.ocsp_status, cs.status_date, + cs.status_valid_until_date, cs.revocation_date) {}; +}; + +struct UnknownCertificateStatus final : public CertificateStatus { + UnknownCertificateStatus() + : CertificateStatus(false, (PVACertStatus)UNKNOWN, (OCSPCertStatus)OCSP_CERTSTATUS_UNKNOWN, std::time(nullptr), (time_t)0, (time_t)0) {}; +}; + +struct UnCertifiedCertificateStatus final : public CertificateStatus { + UnCertifiedCertificateStatus() + : CertificateStatus(false, (PVACertStatus)VALID, (OCSPCertStatus)OCSP_CERTSTATUS_GOOD, std::time(nullptr), std::time(nullptr) + 30 * 60 * 60, + (time_t)0) {}; +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_CERTSTATUS_H_ diff --git a/src/certstatusmanager.cpp b/src/certstatusmanager.cpp new file mode 100644 index 000000000..9bb043a2f --- /dev/null +++ b/src/certstatusmanager.cpp @@ -0,0 +1,493 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The OCSP helper functions. + * + */ + +#include "certstatusmanager.h" + +#include + +#include +#include +#include +#include + +#include +#include + +#include "certstatus.h" +#include "certstatusfactory.h" +#include "configcms.h" +#include "evhelper.h" +#include "ownedptr.h" + +namespace pvxs { +namespace certs { + +DEFINE_LOGGER(status, "pvxs.certs.status"); + +/** + * @brief Retrieves the Online Certificate Status Protocol (OCSP) response from the given byte array. + * + * The getOCSPResponse function takes a shared_array of uint8_t bytes as input and returns the OCSP response. + * The OCSP response is a data structure used to validate the status of an SSL certificate. It contains information + * about the certificate, including its validity and revocation status. + * + * @param ocsp_bytes A shared_array of bytes representing the OCSP response. + * @return The OCSP response as a data structure. + */ +ossl_ptr CertStatusManager::getOCSPResponse(const shared_array& ocsp_bytes) { + // Create a BIO for the OCSP response + ossl_ptr bio(BIO_new_mem_buf(ocsp_bytes.data(), static_cast(ocsp_bytes.size())), false); + if (!bio) { + throw OCSPParseException("Failed to create BIO for OCSP response"); + } + + // Parse the BIO into an OCSP_RESPONSE + ossl_ptr ocsp_response(d2i_OCSP_RESPONSE_bio(bio.get(), nullptr), false); + if (!ocsp_response) { + throw OCSPParseException("Failed to parse OCSP response"); + } + + return ocsp_response; +} + +/** + * Parse OCSP responses from the provided ocsp_bytes response and store the parsed times in the given vectors + * and return the statuses of each certificate contained in the ocsp_bytes response. + * + * First Verify the ocsp response. Check that it is signed by a trusted issuer and that it is well formed. + * + * Then parse it and read out the status and the status times + * + * @param ocsp_bytes The input byte array containing the OCSP responses data. + */ +PVXS_API ParsedOCSPStatus CertStatusManager::parse(const shared_array ocsp_bytes, std::string custom_ca_dir) { + auto ocsp_response = getOCSPResponse(ocsp_bytes); + + // Get the response status + int response_status = OCSP_response_status(ocsp_response.get()); + if (response_status != OCSP_RESPONSE_STATUS_SUCCESSFUL) { + throw OCSPParseException("OCSP response status not successful"); + } + + // Extract the basic OCSP response + ossl_ptr basic_response(OCSP_response_get1_basic(ocsp_response.get()), false); + if (!basic_response) { + throw OCSPParseException("Failed to get basic OCSP response"); + } + + // Verify signature of OCSP response + verifyOCSPResponse(basic_response, custom_ca_dir); + + OCSP_SINGLERESP* single_response = OCSP_resp_get0(basic_response.get(), 0); + if (!single_response) { + throw OCSPParseException("No entries found in OCSP response"); + } + + ASN1_GENERALIZEDTIME *this_update = nullptr, *next_update = nullptr, *revocation_time = nullptr; + int reason = 0; + + // Get the OCSP_CERTID from the single response and extract the serial number + const OCSP_CERTID* cert_id = OCSP_SINGLERESP_get0_id(single_response); + ASN1_INTEGER* serial = nullptr; + OCSP_id_get0_info(nullptr, nullptr, nullptr, &serial, const_cast(cert_id)); + + auto ocsp_status = static_cast(OCSP_single_get0_status(single_response, &reason, &revocation_time, &this_update, &next_update)); + // Check status validity: less than 5 seconds old + OCSP_check_validity(this_update, next_update, 0, 5); + + if (ocsp_status == OCSP_CERTSTATUS_REVOKED && !revocation_time) { + throw OCSPParseException("Revocation time not set when status is REVOKED"); + } + + return {CertStatusFactory::ASN1ToUint64(serial), OCSPCertStatus(ocsp_status), this_update, next_update, revocation_time}; +} + +/** + * @brief Subscribe to the certificate status and wait until the status is available + * + * @param loop the event loop to use to wait + * @param ctx_cert the certificate to monitor + * @param callback the callback to call + * @return a manager of this subscription that you can use to `unsubscribe()`, `waitForValue()` and `getValue()` + */ +cert_status_ptr CertStatusManager::getAndSubscribe(evbase loop, ossl_ptr&& ctx_cert, StatusCallback&& callback) { + auto cert_status_manager = subscribe(std::move(ctx_cert), std::move(callback)); + + // Wait until the status is available + cert_status_manager->waitForStatus(loop); + return cert_status_manager; +} + +/** + * @brief Subscribe to status updates for the given certificate, + * calling the given callback with a CertificateStatus if the status changes. + * It also sets members with the pva certificate status, the status validity period, and a + * revocation date if applicable. + * + * It will not call the callback unless the status update has been verified and + * all errors are ignored. + * + * @param ctx_cert the certificate to monitor + * @param callback the callback to call + * @return a manager of this subscription that you can use to `unsubscribe()`, `waitForValue()` and `getValue()` + */ +cert_status_ptr CertStatusManager::subscribe(ossl_ptr&& ctx_cert, StatusCallback&& callback) { + // Construct the URI + auto uri = CertStatusManager::getStatusPvFromCert(ctx_cert); + log_debug_printf(status, "Starting Status Subscription: %s\n", uri.c_str()); + + // Create a shared_ptr to hold the callback + auto callback_ptr = std::make_shared(std::move(callback)); + + // Subscribe to the service using the constructed URI + // with TLS disabled to avoid recursive loop + auto client(std::make_shared(client::Context::fromEnv(true))); + try { + auto cert_status_manager = cert_status_ptr(new CertStatusManager(std::move(ctx_cert), client)); + log_debug_printf(status, "Subscribing to status: %p\n", cert_status_manager.get()); + auto sub = client->monitor(uri) + .maskConnected(true) + .maskDisconnected(true) + .event([callback_ptr, cert_status_manager](client::Subscription& sub) { + try { + auto update = sub.pop(); + if (update) { + auto status_update((PVACertificateStatus)update); + log_debug_printf(status, "Status subscription received: %s\n", status_update.status.s.c_str()); + cert_status_manager->status_ = std::make_shared(status_update); + (*callback_ptr)(status_update); + } + } catch (client::Finished& conn) { + log_debug_printf(status, "Subscription Finished: %s\n", conn.what()); + } catch (client::Connected& conn) { + log_debug_printf(status, "Connected Subscription: %s\n", conn.peerName.c_str()); + } catch (client::Disconnect& conn) { + log_debug_printf(status, "Disconnected Subscription: %s\n", conn.what()); + } catch (std::exception& e) { + log_err_printf(status, "Error Getting Subscription: %s\n", e.what()); + } + }) + .exec(); + cert_status_manager->subscribe(sub); + log_debug_printf(status, "subscription address: %p\n", cert_status_manager.get()); + return cert_status_manager; + } catch (std::exception& e) { + log_err_printf(status, "Error subscribing to certificate status: %s\n", e.what()); + throw CertStatusSubscriptionException(SB() << "Error subscribing to certificate status: " << e.what()); + } +} + +/** + * @brief Unsubscribe from the certificate status monitoring + */ +void CertStatusManager::unsubscribe() { + client_->hurryUp(); + if (sub_) sub_->cancel(); + if (client_) client_->close(); +} + +/** + * @brief Get status from the manager. + * + * If status has already been retrieved and it is still valid then use that otherwise go get new status + * + * @return the simplified status - does not have ocsp bytes but has been verified and certified + * @see waitForStatus + */ +std::shared_ptr CertStatusManager::getStatus() { + if (isValid()) + return status_; + else + return status_ = getStatus(cert_); +} + +/** + * @brief Get status from the manager. + * + * If status has already been retrieved and it is still valid then use that otherwise go get new status + * + * @return the simplified status - does not have ocsp bytes but has been verified and certified + * @see waitForStatus + */ +std::shared_ptr CertStatusManager::getPVAStatus() { + if (isValid()) + return pva_status_; + else + return pva_status_ = getPVAStatus(cert_); +} + +/** + * @brief Get status for the given cert from the manager. + * + * If status has already been retrieved and it is still valid then use that otherwise go get new status + * + * @param cert the given cert + * @return the simplified status - does not have ocsp bytes but has been verified and certified + * @see waitForStatus + */ +std::shared_ptr CertStatusManager::getStatus(const ossl_ptr& cert) { return std::make_shared(*getPVAStatus(cert)); } + +/** + * @brief Get status for a given uri. Does not contain OCSP signed + * status data. + * + * @param uri the certificate status PV to get status from + * @return std::shared_ptr + */ +std::shared_ptr CertStatusManager::getStatus(const std::string uri) { return std::make_shared(*getPVAStatus(uri)); } + +/** + * @brief Get status for the given cert from the manager. + * + * If status has already been retrieved and it is still valid then use that otherwise go get new status + * + * @param cert the given cert + * @return the simplified status - does not have ocsp bytes but has been verified and certified + * @see waitForStatus + */ +std::shared_ptr CertStatusManager::getPVAStatus(const ossl_ptr& cert) { return getPVAStatus(getStatusPvFromCert(cert)); } + +/** + * @brief Get status from the given uri. This status contains the OCSP signed + * status data so can be used for stapling. + * + * @param uri the uri to GET status from + * @return ::shared_ptr + */ +std::shared_ptr CertStatusManager::getPVAStatus(const std::string uri) { + // Build and start network operation + // use an unsecure socket that doesn't monitor status + auto client(client::Context::forCMS()); + // Wait for status + Value result = client.get(uri).exec()->wait(); + client.close(); + return std::make_shared(result); +} + +/** + * @brief After we have started a subscription for status we may sometimes want to + * wait until the status is available. + * This method waits until the status is returned for up to 3 seconds. If the status has + * already been updated by the subscription then it is returned immediately. + * + * If not it will start a light weight loop to wait for the status to arrive. + * + * @note as long as the status is not UNKNOWN then Certificate status returned will be + * certified and verified. However we don't include the byte array in this light weight + * CertificateStatus that we return. + * + * @param loop the event loop to use to wait + * @return the certificate status at the end of the time - either UNKNOWN still or + * some new value. + */ +std::shared_ptr CertStatusManager::waitForStatus(const evbase& loop) { + auto start(time(nullptr)); + // Timeout 3 seconds + while ((!status_ || !status_->isValid()) && time(nullptr) < start + 3) { + loop.dispatch([]() {}); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + return status_; +} + +/** + * Verifies an OCSP response comes from a trusted source. + * + * @param basic_response An OCSP basic response. + * + * @return Returns true if the OCSP response is valid, false otherwise. + * + * This function takes in an OCSP response and verifies that it was signed by a trusted source. + * It verifies the validity of the OCSP response against the contained CA certificate and its chain, + * and returns a boolean result. + * + * Returns true if the OCSP response is valid, indicating that the certificate in question is from a trusted source. + * Returns false if the OCSP response is invalid or if the certificate in question not to be trusted. + * + * Example usage: + * @code + * shared_array ocsp_bytes = generateOCSPResponse(); // Generates an OCSP response + * ossl_ptr ca_cert = loadCACertificate(); // Loads a CA certificate + * bool isValid = verifyOCSPResponse(ocsp_bytes, ca_cert); // Verifies the OCSP response + * @endcode + */ +bool CertStatusManager::verifyOCSPResponse(const ossl_ptr& basic_response, std::string custom_ca_dir) { + // Get the ca_cert from the response + pvxs::ossl_ptr ca_cert; + OCSP_resp_get0_signer(basic_response.get(), ca_cert.acquire(), nullptr); + + if (!ca_cert) { + throw OCSPParseException("Failed to get signer certificate from OCSP response"); + } + + // get ca_chain + auto const_ca_chain_ptr = OCSP_resp_get0_certs(basic_response.get()); + ossl_ptr ca_chain(sk_X509_dup(const_ca_chain_ptr)); // remove const-ness + + try { + ossl::ensureTrusted(ca_cert, ca_chain); + } catch (std::exception& e) { + throw OCSPParseException(SB() << "verifying OCSP response: " << e.what()); + } + + // Create a new X509_STORE with trusted root CAs + ossl_ptr store(X509_STORE_new(), false); + if (!store) { + throw OCSPParseException("Failed to create X509_STORE to verify OCSP response"); + } + + // Load trusted root CAs from a predefined location + if (X509_STORE_set_default_paths(store.get()) != 1) { + throw OCSPParseException("Failed to load system default CA certificates to verify OCSP response"); + } + + if (!custom_ca_dir.empty()) { + if (X509_STORE_load_locations(store.get(), nullptr, custom_ca_dir.c_str()) != 1) { + throw OCSPParseException(SB() << "Failed to load CA certificates from custom directory: " << custom_ca_dir); + } + } + + // Set up the store context for verification + ossl_ptr ctx(X509_STORE_CTX_new(), false); + if (!ctx) { + throw OCSPParseException("Failed to create X509_STORE_CTX to verify OCSP response"); + } + + if (X509_STORE_CTX_init(ctx.get(), store.get(), ca_cert.get(), ca_chain.get()) != 1) { + throw OCSPParseException("Failed to initialize X509_STORE_CTX to verify CA certificate"); + } + + // Verification parameters + X509_STORE_CTX_set_flags(ctx.get(), + X509_V_FLAG_PARTIAL_CHAIN | // Succeed as soon as at least one intermediary is trusted + X509_V_FLAG_CHECK_SS_SIGNATURE | // Allow self-signed root CA + X509_V_FLAG_TRUSTED_FIRST // Check the trusted locations first + ); + + // Add the now trusted ca certificate from the response to the store + if (X509_STORE_add_cert(store.get(), ca_cert.get()) != 1) { + throw OCSPParseException("Failed to add issuer certificate to X509_STORE to verify OCSP response"); + } + + // Add certificates from ca_chain to the store + if (ca_chain) { + for (int i = 0; i < sk_X509_num(ca_chain.get()); i++) { + X509* cert = sk_X509_value(ca_chain.get(), i); + if (X509_STORE_add_cert(store.get(), cert) != 1) { + // Log warning but continue + log_warn_printf(status, "Failed to add certificate from chain to X509_STORE%s\n", ""); + } + } + } + + // Now that we've verified the CA cert, we can use it to verify the OCSP response. Values greater than 0 mean verified + int verify_result = OCSP_basic_verify(basic_response.get(), ca_chain.get(), store.get(), 0); + if (verify_result <= 0) { + throw OCSPParseException("OCSP_basic_verify failed"); + } + + return true; +} + +/** + * @brief Call this method to see if we should monitor the given certificate + * This will return true if there is our custom extension in the certificate. + * It will produce various exceptions to tell you if it failed to look. + * Otherwise the boolean returned indicates whether the certificate status is + * valid only when monitored. + * + * @param certificate certificate to check + * @return true if we should monitor the given certificate + */ +bool CertStatusManager::shouldMonitor(const ossl_ptr& certificate) { + return (X509_get_ext_by_NID(certificate.get(), ossl::SSLContext::NID_PvaCertStatusURI, -1) >= 0); +} + +/** + * @brief Get the string value of a custom extension by NID from a certificate. + * This will return the PV name to monitor for status of the given certificate. + * It is stored in the certificate using a custom extension. + * Exceptions are thrown if it is unable to retrieve the value of the extension + * or it does not exist. + * @param certificate the certificate to examine + * @return the PV name to call for status on that certificate + */ +std::string CertStatusManager::getStatusPvFromCert(const ossl_ptr& certificate) { return getStatusPvFromCert(certificate.get()); } + +/** + * @brief Check if status monitoring is required for the given certificate. + * This method checks if the given certificate has the custom extension with the NID_PvaCertStatusURI. + * If such an extension is found, it returns true, indicating that status monitoring is required. + * If no such extension is found, it returns false, indicating that status monitoring is not required. + * @param certificate the certificate to check for status monitoring requirement + * @return true if status monitoring is required, false otherwise + */ +bool CertStatusManager::statusMonitoringRequired(const X509* certificate) { + try { + getExtension(certificate); + return true; + } catch (...) { + } + return false; +} + +/** + * @brief Get the extension from the certificate. + * This method retrieves the extension from the given certificate using the NID_PvaCertStatusURI. + * If the extension is not found, it throws a CertStatusNoExtensionException. + * @param certificate the certificate to retrieve the extension from + * @return the X509_EXTENSION object if found, otherwise throws an exception + */ +X509_EXTENSION* CertStatusManager::getExtension(const X509* certificate) { + int extension_index = X509_get_ext_by_NID(certificate, ossl::SSLContext::NID_PvaCertStatusURI, -1); + if (extension_index < 0) throw CertStatusNoExtensionException("Failed to find extension index"); + + // Get the extension object from the certificate + X509_EXTENSION* extension = X509_get_ext(certificate, extension_index); + if (!extension) { + throw CertStatusNoExtensionException("Failed to get extension from the certificate."); + } + return extension; +} + +/** + * @brief Get the string value of a custom extension by NID from a certificate. + * This will return the PV name to monitor for status of the given certificate. + * It is stored in the certificate using a custom extension. + * Exceptions are thrown if it is unable to retrieve the value of the extension + * or it does not exist. + * @param certificate the certificate to examine + * @return the PV name to call for status on that certificate + */ +std::string CertStatusManager::getStatusPvFromCert(const X509* certificate) { + auto extension = getExtension(certificate); + + // Retrieve the extension data which is an ASN1_OCTET_STRING object + ASN1_OCTET_STRING* ext_data = X509_EXTENSION_get_data(extension); + if (!ext_data) { + throw CertStatusNoExtensionException("Failed to get data from the extension."); + } + + // Get the data as a string + const unsigned char* data = ASN1_STRING_get0_data(ext_data); + if (!data) { + throw CertStatusNoExtensionException("Failed to extract data from ASN1_STRING."); + } + + int length = ASN1_STRING_length(ext_data); + if (length < 0) { + throw CertStatusNoExtensionException("Invalid length of ASN1_STRING data."); + } + + // Return the data as a std::string + return std::string(reinterpret_cast(data), length); +} +} // namespace certs +} // namespace pvxs diff --git a/src/certstatusmanager.h b/src/certstatusmanager.h new file mode 100644 index 000000000..e64212b3b --- /dev/null +++ b/src/certstatusmanager.h @@ -0,0 +1,346 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +/** + * The certificate status manager class + * + * certstatusmanager.h + * + */ +#ifndef PVXS_CERTSTATUSMANAGER_H_ +#define PVXS_CERTSTATUSMANAGER_H_ + +#include + +#include +#include +#include +#include + +// #include +#include + +#include "certstatus.h" +#include "evhelper.h" +#include "ownedptr.h" + +#define DO_CERT_STATUS_VALIDITY_EVENT_HANDLER(TYPE, STATUS_CALL) \ + void TYPE::doCertStatusValidityEventhandler(evutil_socket_t fd, short evt, void* raw) { \ + auto pvt = static_cast(raw); \ + if (pvt->current_status && pvt->current_status->isValid()) return; \ + if (!pvt->cert_status_manager) \ + pvt->disableTls(); \ + else { \ + try { \ + try { \ + pvt->current_status = pvt->cert_status_manager->STATUS_CALL(); \ + if (pvt->current_status && pvt->current_status->isGood()) \ + pvt->startStatusValidityTimer(); \ + else \ + pvt->disableTls(); \ + } catch (certs::CertStatusNoExtensionException & e) { \ + } \ + } catch (...) { \ + pvt->disableTls(); \ + } \ + } \ + } + +#define CUSTOM_FILE_EVENT_CALL \ + if (pvt->custom_cert_event_callback) { \ + pvt->custom_cert_event_callback(evt); \ + } + +#define _FILE_EVENT_CALL + +#define DO_CERT_EVENT_HANDLER(TYPE, LOG, ...) \ + void TYPE::doCertEventHandler(evutil_socket_t fd, short evt, void* raw) { \ + try { \ + auto pvt = static_cast(raw); \ + __VA_ARGS__##_FILE_EVENT_CALL pvt->fileEventCallback(evt); \ + if (pvt->first_cert_event) pvt->first_cert_event = false; \ + timeval interval(statusIntervalShort); \ + if (event_add(pvt->cert_event_timer.get(), &interval)) log_err_printf(LOG, "Error re-enabling cert file event timer\n%s", ""); \ + } catch (std::exception & e) { \ + log_exc_printf(LOG, "Unhandled error in cert file event timer callback: %s\n", e.what()); \ + } \ + } + +#define FILE_EVENT_CALLBACK(TYPE) \ + void TYPE::fileEventCallback(short evt) { \ + if (!first_cert_event) file_watcher.checkFileStatus(); \ + } + +#define GET_CERT(TYPE) \ + X509* TYPE::getCert(ossl::SSLContext* context_ptr) { \ + auto context = context_ptr == nullptr ? &tls_context : context_ptr; \ + if (!context->ctx) return nullptr; \ + return SSL_CTX_get0_certificate(context->ctx); \ + } + +#define START_STATUS_VALIDITY_TIMER(TYPE, LOOP) \ + void TYPE::startStatusValidityTimer() { \ + (LOOP).dispatch([this]() { \ + if (current_status) { \ + auto now = time(nullptr); \ + timeval validity_end = {current_status->status_valid_until_date.t - now, 0}; \ + if (event_add(cert_validity_timer.get(), &validity_end)) log_err_printf(watcher, "Error starting certificate status validity timer\n%s", ""); \ + } \ + }); \ + } + +#define SUBSCRIBE_TO_CERT_STATUS(TYPE, STATUS_TYPE, LOOP) \ + void TYPE::subscribeToCertStatus() { \ + if (auto cert_ptr = getCert()) { \ + try { \ + if (cert_status_manager) return; \ + auto ctx_cert = ossl_ptr(X509_dup(cert_ptr)); \ + cert_status_manager = certs::CertStatusManager::subscribe(std::move(ctx_cert), [this](certs::PVACertificateStatus status) { \ + Guard G(tls_context.lock); \ + auto was_good = current_status && current_status->isGood(); \ + current_status = std::make_shared(status); \ + if (current_status && current_status->isGood()) { \ + if (!was_good) (LOOP).dispatch([this]() mutable { enableTls(); }); \ + } else if (was_good) { \ + (LOOP).dispatch([this]() mutable { disableTls(); }); \ + } \ + }); \ + } catch (certs::CertStatusSubscriptionException & e) { \ + log_warn_printf(watcher, "TLS Disabled: %s\n", e.what()); \ + } catch (certs::CertStatusNoExtensionException & e) { \ + log_debug_printf(watcher, "Status monitoring not configured correctly: %s\n", e.what()); \ + } \ + } \ + } + +namespace pvxs { + +// Forward def +namespace client { +class Context; +struct Subscription; +} // namespace client + +namespace certs { + +template +struct cert_status_delete; + +template +using cert_status_ptr = ossl_shared_ptr>; + +/** + * @brief This class is used to parse OCSP responses and to get/subscribe to certificate status + * + * Parsing OCSP responses is carried out by providing the OCSP response buffer + * to the static `parse()` function. This function will verify the response comes + * from a trusted source, is well formed, and then will return the `OCSPStatus` + * it indicates. + * @code + * auto ocsp_status(CertStatusManager::parse(ocsp_response); + * @endcode + * + * To get certificate status call the status `getStatus()` method with the + * the certificate you want to get status for. It will make a request + * to the PVACMS to get certificate status for the certificate. After verifying the + * authenticity of the response and checking that it is from a trusted + * source it will return `CertificateStatus`. + * @code + * auto cert_status(CertStatusManager::getStatus(cert); + * @endcode + * + * To subscribe, call the subscribe method with the certificate you want to + * subscribe to status for and provide a callback that takes a `CertificateStatus` + * to be notified of status changes. It will subscribe to PVACMS to monitor changes to + * to the certificate status for the given certificate. After verifying the + * authenticity of each status update and checking that it is from a trusted + * source it will call the callback with a `CertificateStatus` representing the + * updated status. + * @code + * auto csm = CertStatusManager::subscribe(cert, [] (CertificateStatus &&cert_status) { + * std::cout << "STATUS DATE: " << cert_status.status_date.s << std::endl; + * }); + * ... + * csm.unsubscribe(); + * // unsubscribe() automatically called when csm goes out of scope + * @endcode + */ +class CertStatusManager { + public: + friend struct OCSPStatus; + CertStatusManager() = delete; + + virtual ~CertStatusManager() = default; + + using StatusCallback = std::function; + + static bool shouldMonitor(const ossl_ptr& certificate); + + /** + * @brief Get the status PV from a Cert. + * This function gets the PVA extension that stores the status PV in the certificate + * if the certificate must be used in conjunction with a status monitor to check for + * revoked status. + * @param cert the certificate to check for the status PV extension + * @return a blank string if no extension exists, otherwise contains the status PV + * e.g. CERT:STATUS:0293823f:098294739483904875 + */ + static std::string getStatusPvFromCert(const ossl_ptr& cert); + + /** + * @brief Get the custom status extension from the given certificate + * @param certificate the certificate to retrieve the status extension from + * @return the extension + * @throws CertStatusNoExtensionException if no extension is present in the certificate + */ + static X509_EXTENSION* getExtension(const X509* certificate); + + /** + * @brief Determine if status monitoring is required for the given certificate + * @param certificate the certificate to check + * @return true if certificate monitoring is required + */ + static bool statusMonitoringRequired(const X509* certificate); + + /** + * @brief Get the status PV from a Cert. + * This function gets the PVA extension that stores the status PV in the certificate + * if the certificate must be used in conjunction with a status monitor to check for + * revoked status. + * @param cert the certificate to check for the status PV extension + * @return a blank string if no extension exists, otherwise contains the status PV + * e.g. CERT:STATUS:0293823f:098294739483904875 + */ + static std::string getStatusPvFromCert(const X509* cert); + + /** + * @brief Used to create a helper that you can use to subscribe to certificate status with + * Subsequently call subscribe() to subscribe + * + * @param ctx_cert certificate you want to subscribe to + * @param callback the callback to call when a status change has appeared + * + * @see unsubscribe() + */ + static cert_status_ptr subscribe(ossl_ptr&& ctx_cert, StatusCallback&& callback); + + static cert_status_ptr getAndSubscribe(evbase loop, ossl_ptr&& ctx_cert, StatusCallback&& callback); + + /** + * @brief Get status for a given certificate. Does not contain OCSP signed + * status data so use for client status. + * + * @param cert the certificate for which you want to get status + * @return std::shared_ptr + */ + static std::shared_ptr getStatus(const ossl_ptr& cert); + + /** + * @brief Get status for a given uri. Does not contain OCSP signed + * status data. + * + * @param uri the certificate status PV to get status from + * @return std::shared_ptr + */ + static std::shared_ptr getStatus(const std::string uri); + + /** + * @brief Get status for a given certificate. This status contains the OCSP signed + * status data so can be used for stapling. Use this for server status. + * + * @param cert the certificate for which you want to get status + * @return ::shared_ptr + */ + static std::shared_ptr getPVAStatus(const ossl_ptr& cert); + + /** + * @brief Get status from the given uri. This status contains the OCSP signed + * status data so can be used for stapling. + * + * @param uri the uri to GET status from + * @return ::shared_ptr + */ + static std::shared_ptr getPVAStatus(const std::string uri); + + /** + * @brief Wait for status to become available or return the current status if it is still valid + * @param loop the event loop base to use to wait + * @return the status + */ + std::shared_ptr waitForStatus(const evbase& loop); + + /** + * @brief Unsubscribe from listening to certificate status + * + * This function idempotent unsubscribe from the certificate status updates + */ + void unsubscribe(); + + /** + * @brief Get status for a currently subscribed certificate + * @return CertificateStatus + */ + std::shared_ptr getStatus(); + + /** + * @brief Get status for a currently subscribed certificate + * @return CertificateStatus + */ + std::shared_ptr getPVAStatus(); + + inline bool available(double timeout = 5.0) noexcept { return isValid() || waitedTooLong(timeout); } + + inline bool waitedTooLong(double timeout = 5.0) const noexcept { return (manager_start_time_ + (time_t)timeout) < std::time(nullptr); } + + inline bool isValid() noexcept { return status_ && status_->isValid(); } + + private: + CertStatusManager(ossl_ptr&& cert, std::shared_ptr& client, std::shared_ptr& sub) + : cert_(std::move(cert)), client_(client), sub_(sub) {}; + CertStatusManager(ossl_ptr&& cert, std::shared_ptr& client) : cert_(std::move(cert)), client_(client) {}; + inline void subscribe(std::shared_ptr& sub) { sub_ = sub; } + inline bool isGood() noexcept { return status_ && status_->isGood(); } + + const ossl_ptr cert_; + std::shared_ptr client_; + std::shared_ptr sub_; + std::shared_ptr status_; + std::shared_ptr pva_status_; + time_t manager_start_time_{time(nullptr)}; + static ossl_ptr getOCSPResponse(const shared_array& ocsp_bytes); + + static bool verifyOCSPResponse(const ossl_ptr& basic_response, std::string custom_ca_dir); + + /** + * @brief To parse OCSP responses + * + * Parsing OCSP responses is carried out by providing the OCSP response buffer. + * This function will verify the response comes from a trusted source, + * is well formed, and then will return the `ParsedOCSPStatus` it indicates. + * + * @param ocsp_bytes the ocsp response + * @return the Parsed OCSP response status + */ + public: + static ParsedOCSPStatus parse(const shared_array ocsp_bytes, std::string custom_ca_dir = {}); + + private: + std::vector ocspResponseToBytes(const pvxs::ossl_ptr& basic_resp); +}; + +template <> +struct cert_status_delete { + inline void operator()(CertStatusManager* base_pointer) { + if (base_pointer) { + base_pointer->unsubscribe(); // Idempotent unsubscribe + delete base_pointer; + } + } +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_CERTSTATUSMANAGER_H_ diff --git a/src/client.cpp b/src/client.cpp index b33b06dec..56a37b3ea 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -8,18 +8,23 @@ #include #include -#include +#include #include -#include #include +#include +#include #include -#include -DEFINE_LOGGER(setup, "pvxs.client.setup"); -DEFINE_LOGGER(io, "pvxs.client.io"); -DEFINE_LOGGER(beacon, "pvxs.client.beacon"); -DEFINE_LOGGER(duppv, "pvxs.client.dup"); +#include "certstatusmanager.h" +#include "p12filewatcher.h" + +DEFINE_LOGGER(setup, "pvxs.cli.init"); +DEFINE_LOGGER(watcher, "pvxs.certs.mon"); +DEFINE_LOGGER(filemon, "pvxs.file.mon"); +DEFINE_LOGGER(io, "pvxs.cli.io"); +DEFINE_LOGGER(beacon, "pvxs.cli.beacon"); +DEFINE_LOGGER(duppv, "pvxs.cli.dup"); typedef epicsGuard Guard; typedef epicsGuardRelease UnGuard; @@ -36,10 +41,10 @@ namespace { /* "normal" tick interval for the search bucket ring, and "fast" interval * used for one revolution after a successful poke(). */ -constexpr timeval bucketInterval{1,0}; -constexpr timeval bucketIntervalFast{0,200000}; +constexpr timeval bucketInterval{1, 0}; +constexpr timeval bucketIntervalFast{0, 200000}; // coalescence time for first search for a batch of newly created Channels -constexpr timeval initialSearchDelay{0, 10000}; // 10 ms +constexpr timeval initialSearchDelay{0, 10000}; // 10 ms // number of buckets in the search ring constexpr size_t nBuckets = 30u; @@ -53,7 +58,7 @@ constexpr size_t maxSearchPayload = 1400; /* Interval between checks for Channels which are no longer used by any operation. * Channels will be discarded if found to be unused by two consecutive checks. */ -constexpr timeval channelCacheCleanInterval{10,0}; +constexpr timeval channelCacheCleanInterval{10, 0}; // time to wait before allowing another hurryUp(). constexpr double pokeHoldoff = 30.0; @@ -69,74 +74,50 @@ constexpr timeval tcpNSCheckInterval{10, 0}; // searchSequenceID in CMD_SEARCH is redundant. // So we use a static value and instead rely on IDs for individual PVs -constexpr uint32_t search_seq{0x66696e64}; // "find" -} // namespace +constexpr uint32_t search_seq{0x66696e64}; // "find" +} // namespace -Disconnect::Disconnect() - :std::runtime_error("Disconnected") - ,time(epicsTime::getCurrent()) -{} +Disconnect::Disconnect() : std::runtime_error("Disconnected"), time(epicsTime::getCurrent()) {} Disconnect::~Disconnect() {} -RemoteError::RemoteError(const std::string& msg) - :std::runtime_error(msg) -{} +RemoteError::RemoteError(const std::string& msg) : std::runtime_error(msg) {} RemoteError::~RemoteError() {} Finished::~Finished() {} -Connected::Connected(const std::string& peerName) - :std::runtime_error("Connected") - ,peerName(peerName) - ,time(epicsTime::getCurrent()) -{} +Connected::Connected(const std::string& peerName, const epicsTime& time, const std::shared_ptr& cred) + : std::runtime_error("Connected"), peerName(peerName), time(time), cred(cred) {} Connected::~Connected() {} -Interrupted::Interrupted() - :std::runtime_error ("Interrupted") -{} +Interrupted::Interrupted() : std::runtime_error("Interrupted") {} Interrupted::~Interrupted() {} -Timeout::Timeout() - :std::runtime_error ("Timeout") -{} +Timeout::Timeout() : std::runtime_error("Timeout") {} Timeout::~Timeout() {} -Channel::Channel(const std::shared_ptr& context, const std::string& name, uint32_t cid) - :context(context) - ,name(name) - ,cid(cid) -{} +Channel::Channel(const std::shared_ptr& context, const std::string& name, uint32_t cid) : context(context), name(name), cid(cid) {} -Channel::~Channel() -{ - disconnect(nullptr); -} +Channel::~Channel() { disconnect(nullptr); } -void Channel::createOperations() -{ - if(state!=Channel::Active) - return; +void Channel::createOperations() { + if (state != Channel::Active) return; auto todo = std::move(pending); - for(auto& wop : todo) { + for (auto& wop : todo) { auto op = wop.lock(); - if(!op) - continue; + if (!op) continue; uint32_t ioid; do { ioid = conn->nextIOID++; - } while(conn->opByIOID.find(ioid)!=conn->opByIOID.end()); + } while (conn->opByIOID.find(ioid) != conn->opByIOID.end()); - //conn->opByIOID.insert(std::make_pair(ioid, RequestInfo(sid, ioid, op))); - auto pair = conn->opByIOID.emplace(std::piecewise_construct, - std::forward_as_tuple(ioid), - std::forward_as_tuple(sid, ioid, op)); + // conn->opByIOID.insert(std::make_pair(ioid, RequestInfo(sid, ioid, op))); + auto pair = conn->opByIOID.emplace(std::piecewise_construct, std::forward_as_tuple(ioid), std::forward_as_tuple(sid, ioid, op)); opByIOID[ioid] = &pair.first->second; op->ioid = ioid; @@ -147,32 +128,31 @@ void Channel::createOperations() // call on disconnect or CMD_DESTROY_CHANNEL // detach from Connection and notify Connect and *Op -void Channel::disconnect(const std::shared_ptr& self) -{ - assert(!self || this==self.get()); +void Channel::disconnect(const std::shared_ptr& self) { + assert(!self || this == self.get()); auto current(std::move(conn)); size_t holdoff = 0u; - switch(state) { - case Channel::Connecting: - current->pending.erase(cid); - /* disconnect/timeout while before CREATE_CHANNEL sent, - * likely lower level networking issue. Try to slow - * down reconnect loop. - */ - holdoff = 10u; // arbitrary - break; - case Channel::Creating: - current->creatingByCID.erase(cid); - break; - case Channel::Active: - current->chanBySID.erase(sid); - break; - default: - break; + switch (state) { + case Channel::Connecting: + current->pending.erase(cid); + /* disconnect/timeout while before CREATE_CHANNEL sent, + * likely lower level networking issue. Try to slow + * down reconnect loop. + */ + holdoff = 10u; // arbitrary + break; + case Channel::Creating: + current->creatingByCID.erase(cid); + break; + case Channel::Active: + current->chanBySID.erase(sid); + break; + default: + break; } - if((state==Creating || state==Active) && current && current->connection()) { + if ((state == Creating || state == Active) && current && current->connection()) { { (void)evbuffer_drain(current->txBody.get(), evbuffer_get_length(current->txBody.get())); @@ -185,44 +165,44 @@ void Channel::disconnect(const std::shared_ptr& self) } state = Channel::Searching; - sid = 0xdeadbeef; // spoil + sid = 0xdeadbeef; // spoil - auto conns(connectors); // copy list + auto conns(connectors); // copy list - for(auto& interested : conns) { - if(interested->_connected.exchange(false, std::memory_order_relaxed) && interested->_onDis) - interested->_onDis(); + for (auto& interested : conns) { + if (interested->_connected.exchange(false, std::memory_order_relaxed) && interested->_onDis) interested->_onDis(); } auto ops(std::move(opByIOID)); - for(auto& pair : ops) { + for (auto& pair : ops) { auto op = pair.second->handle.lock(); current->opByIOID.erase(pair.first); - if(op) - op->disconnected(op); + if (op) op->disconnected(op); } - if(!self) { // in ~Channel + if (!self) { // in ~Channel // searchBuckets cleaned in tickSearch() - } else if(forcedServer.family()==AF_UNSPEC) { // begin search + } else if (forcedServer.addr.family() == AF_UNSPEC) { // begin search auto next = (context->currentBucket + holdoff) % nBuckets; context->searchBuckets[next].push_back(self); - log_debug_printf(io, "Server %s detach channel '%s' to re-search\n", - current ? current->peerName.c_str() : "", - name.c_str()); + log_debug_printf(io, "Server %s detach channel '%s' to re-search\n", current ? current->peerName.c_str() : "", name.c_str()); - } else if(context->state==ContextImpl::Running) { // reconnect to specific server - conn = Connection::build(context, forcedServer, true); + } else if (context->state == ContextImpl::Running) { // reconnect to specific server + conn = Connection::build(context, forcedServer.addr, true +#ifdef PVXS_ENABLE_OPENSSL + , + forcedServer.scheme == SockEndpoint::TLS +#endif + ); conn->pending[cid] = self; state = Connecting; conn->createChannels(); - } } @@ -230,23 +210,14 @@ Connect::~Connect() {} ConnectImpl::~ConnectImpl() {} -const std::string& ConnectImpl::name() const -{ - return _name; -} -bool ConnectImpl::connected() const -{ - return _connected.load(std::memory_order_relaxed); -} +const std::string& ConnectImpl::name() const { return _name; } +bool ConnectImpl::connected() const { return _connected.load(std::memory_order_relaxed); } -std::shared_ptr ConnectBuilder::exec() -{ - if(!ctx) - throw std::logic_error("NULL Builder"); +std::shared_ptr ConnectBuilder::exec() { + if (!ctx) throw std::logic_error("NULL Builder"); auto syncCancel(_syncCancel); auto context(ctx->impl->shared_from_this()); - auto op(std::make_shared(context->tcp_loop, _pvname)); op->_onConn = std::move(_onConn); op->_onDis = std::move(_onDis); @@ -257,136 +228,122 @@ std::shared_ptr ConnectBuilder::exec() auto loop(temp->loop); // std::bind for lack of c++14 generalized capture // to move internal ref to worker for dtor - loop.tryInvoke(syncCancel, std::bind([](std::shared_ptr& op) { - // on worker - - // ordering of dispatch()/call() ensures creation before destruction - assert(op->chan); - op->chan->connectors.remove(op.get()); - }, std::move(temp))); + loop.tryInvoke(syncCancel, std::bind( + [](std::shared_ptr& op) { + // on worker + + // ordering of dispatch()/call() ensures creation before destruction + assert(op->chan); + op->chan->connectors.remove(op.get()); + }, + std::move(temp))); }); auto server(std::move(_server)); - context->tcp_loop.dispatch([op, context, server]() { - // on worker - - op->chan = Channel::build(context, op->_name, server); - - bool cur = op->_connected = op->chan->state==Channel::Active; - if(cur && op->_onConn) - op->_onConn(); - else if(!cur && op->_onDis) - op->_onDis(); - - op->chan->connectors.push_back(op.get()); - }); + context->tcp_loop.dispatch([=]() { + // on worker + op->chan = Channel::build(context, op->_name, server); + + bool cur = op->_connected = op->chan->state == Channel::Active; + if (cur && op->_onConn) { + auto& conn = op->chan->conn; + Connected evt(conn->peerName, conn->connTime, conn->cred); + op->_onConn(evt); + } else if (!cur && op->_onDis) { + op->_onDis(); + } + op->chan->connectors.push_back(op.get()); + } + ); return external; } -Value ResultWaiter::wait(double timeout) -{ +Value ResultWaiter::wait(double timeout) { Guard G(lock); - while(outcome==Busy) { + while (outcome == Busy) { UnGuard U(G); - if(!notify.wait(timeout)) - throw Timeout(); + if (!notify.wait(timeout)) throw Timeout(); } - if(outcome==Done) + if (outcome == Done) return result(); else throw Interrupted(); } -void ResultWaiter::complete(Result&& result, bool interrupt) -{ +void ResultWaiter::complete(Result&& result, bool interrupt) { { Guard G(lock); - if(outcome!=Busy) - return; + if (outcome != Busy) return; this->result = std::move(result); outcome = interrupt ? Abort : Done; } notify.signal(); } -OperationBase::OperationBase(operation_t op, const evbase& loop) - :Operation(op) - ,loop(loop) -{} +OperationBase::OperationBase(operation_t op, const evbase& loop) : Operation(op), loop(loop) {} OperationBase::~OperationBase() {} -const std::string& OperationBase::name() -{ - return chan->name; -} +const std::string& OperationBase::name() { return chan->name; } -Value OperationBase::wait(double timeout) -{ - if(!waiter) - throw std::logic_error("Operation has custom .result() callback"); +Value OperationBase::wait(double timeout) { + if (!waiter) throw std::logic_error("Operation has custom .result() callback"); return waiter->wait(timeout); } -void OperationBase::interrupt() -{ - if(waiter) - waiter->complete(Result(), true); +void OperationBase::interrupt() { + if (waiter) waiter->complete(Result(), true); } -RequestInfo::RequestInfo(uint32_t sid, uint32_t ioid, std::shared_ptr& handle) - :sid(sid) - ,ioid(ioid) - ,op(handle->op) - ,handle(handle) -{} +RequestInfo::RequestInfo(uint32_t sid, uint32_t ioid, std::shared_ptr& handle) : sid(sid), ioid(ioid), op(handle->op), handle(handle) {} -std::shared_ptr Channel::build(const std::shared_ptr& context, - const std::string& name, - const std::string& server) -{ - if(context->state!=ContextImpl::Running) - throw std::logic_error("Context close()d"); +std::shared_ptr Channel::build(const std::shared_ptr& context, const std::string& name, const std::string& server) { + if (context->state != ContextImpl::Running) throw std::logic_error("Context close()d"); - SockAddr forceServer; - decltype (context->chanByName)::key_type namekey(name, server); + SockEndpoint forceServer; + decltype(context->chanByName)::key_type namekey(name, server); - if(!server.empty()) { - forceServer.setAddress(server.c_str(), context->effective.tcp_port); + if (!server.empty()) { + SockEndpoint temp(server.c_str(), &context->effective); + if (!temp.iface.empty() || temp.ttl != -1) throw std::runtime_error(SB() << "interface or TTL restriction not supported for .server(): " << server); + forceServer = std::move(temp); } std::shared_ptr chan; auto it = context->chanByName.find(namekey); - if(it!=context->chanByName.end()) { + if (it != context->chanByName.end()) { chan = it->second; chan->garbage = false; } - if(!chan) { - while(context->chanByCID.find(context->nextCID)!=context->chanByCID.end()) - context->nextCID++; + if (!chan) { + while (context->chanByCID.find(context->nextCID) != context->chanByCID.end()) context->nextCID++; chan = std::make_shared(context, name, context->nextCID); context->chanByCID[chan->cid] = chan; context->chanByName[namekey] = chan; - if(server.empty()) { + if (server.empty()) { context->initialSearchBucket.push_back(chan); context->scheduleInitialSearch(); - } else { // bypass search and connect so a specific server + } else { // bypass search and connect to a specific server chan->forcedServer = forceServer; - chan->conn = Connection::build(context, forceServer); + chan->conn = Connection::build(context, forceServer.addr, false +#ifdef PVXS_ENABLE_OPENSSL + , + forceServer.scheme == SockEndpoint::TLS +#endif + ); chan->conn->pending[chan->cid] = chan; chan->state = Connecting; chan->conn->createChannels(); - } } @@ -397,51 +354,52 @@ Operation::~Operation() {} Subscription::~Subscription() {} -Context Context::fromEnv() -{ - return Config::fromEnv().build(); +#ifndef PVXS_ENABLE_OPENSSL +Context Context::fromEnv() { return Config::fromEnv().build(); } +#else +Context Context::fromEnv(const bool tls_disabled) { return Config::fromEnv(tls_disabled).build(); } +Context Context::forCMS() { + auto env_config = Config::fromEnv(true); + auto config_to_use = Config{}; + config_to_use.udp_port = env_config.udp_port; + config_to_use.tcp_port = env_config.tcp_port; + config_to_use.interfaces = env_config.interfaces; + config_to_use.addressList = env_config.addressList; + config_to_use.autoAddrList = env_config.autoAddrList; + config_to_use.tls_disabled = false; + config_to_use.is_initialized = true; + return config_to_use.build(); } +Context::Context(const Config& conf, const std::function& fn) : pvt(std::make_shared(conf)) { pvt->impl->startNS(); } -Context::Context(const Config& conf) - :pvt(std::make_shared(conf)) -{ - pvt->impl->startNS(); -} +#endif // PVXS_ENABLE_OPENSSL + +Context::Context(const Config& conf) : pvt(std::make_shared(conf)) { pvt->impl->startNS(); } Context::~Context() {} -const Config& Context::config() const -{ - if(!pvt) - throw std::logic_error("NULL Context"); +const Config& Context::config() const { + if (!pvt) throw std::logic_error("NULL Context"); return pvt->impl->effective; } -void Context::close() -{ - if(!pvt) - throw std::logic_error("NULL Context"); +void Context::close() { + if (!pvt) throw std::logic_error("NULL Context"); pvt->impl->close(); } -void Context::hurryUp() -{ - if(!pvt) - throw std::logic_error("NULL Context"); +void Context::hurryUp() { + if (!pvt) throw std::logic_error("NULL Context"); - pvt->impl->manager.loop().call([this](){ - pvt->impl->poke(); - }); + pvt->impl->manager.loop().call([this]() { pvt->impl->poke(); }); } -void Context::cacheClear(const std::string& name, cacheAction action) -{ - if(!pvt) - throw std::logic_error("NULL Context"); +void Context::cacheClear(const std::string& name, cacheAction action) { + if (!pvt) throw std::logic_error("NULL Context"); - pvt->impl->tcp_loop.call([this, name, action](){ + pvt->impl->tcp_loop.call([this, name, action]() { // run twice to ensure both mark and sweep of all unused channels log_debug_printf(setup, "cacheClear('%s')\n", name.c_str()); pvt->impl->cacheClean(name, action); @@ -449,26 +407,19 @@ void Context::cacheClear(const std::string& name, cacheAction action) }); } -void Context::ignoreServerGUIDs(const std::vector& guids) -{ - if(!pvt) - throw std::logic_error("NULL Context"); +void Context::ignoreServerGUIDs(const std::vector& guids) { + if (!pvt) throw std::logic_error("NULL Context"); - pvt->impl->manager.loop().call([this, &guids](){ - pvt->impl->ignoreServerGUIDs = guids; - }); + pvt->impl->manager.loop().call([this, &guids]() { pvt->impl->ignoreServerGUIDs = guids; }); } -Report Context::report(bool zero) const -{ +Report Context::report(bool zero) const { Report ret; - pvt->impl->tcp_loop.call([this, &ret, zero](){ - - for(auto& pair : pvt->impl->connByAddr) { + pvt->impl->tcp_loop.call([this, &ret, zero]() { + for (auto& pair : pvt->impl->connByAddr) { auto conn = pair.second.lock(); - if(!conn) - continue; + if (!conn) continue; ret.connections.emplace_back(); auto& sconn = ret.connections.back(); @@ -476,16 +427,15 @@ Report Context::report(bool zero) const sconn.tx = conn->statTx; sconn.rx = conn->statRx; - if(zero) { + if (zero) { conn->statTx = conn->statRx = 0u; } // omit stats for transitory conn->creatingByCID - for(auto& pair : conn->chanBySID) { + for (auto& pair : conn->chanBySID) { auto chan = pair.second.lock(); - if(!chan) - continue; + if (!chan) continue; sconn.channels.emplace_back(); auto& schan = sconn.channels.back(); @@ -493,59 +443,105 @@ Report Context::report(bool zero) const schan.tx = chan->statTx; schan.rx = chan->statRx; - if(zero) { + if (zero) { chan->statTx = chan->statRx = 0u; } } } - }); return ret; } -static -Value buildCAMethod() -{ +static Value buildCAMethod() { using namespace pvxs::members; - return TypeDef(TypeCode::Struct, { + return TypeDef(TypeCode::Struct, + { String("user"), String("host"), - }).create(); + }) + .create(); } ContextImpl::ContextImpl(const Config& conf, const evbase& tcp_loop) - :ifmap(IfaceMap::instance()) - ,effective([conf]() -> Config{ - Config eff(conf); - eff.expand(); - return eff; - }()) - ,caMethod(buildCAMethod()) - ,searchTx4(AF_INET, SOCK_DGRAM, 0) - ,searchTx6(AF_INET6, SOCK_DGRAM, 0) - ,tcp_loop(tcp_loop) - ,searchRx4(__FILE__, __LINE__, - event_new(tcp_loop.base, searchTx4.sock, EV_READ|EV_PERSIST, &ContextImpl::onSearchS, this)) - ,searchRx6(__FILE__, __LINE__, - event_new(tcp_loop.base, searchTx6.sock, EV_READ|EV_PERSIST, &ContextImpl::onSearchS, this)) - ,searchTimer(__FILE__, __LINE__, - event_new(tcp_loop.base, -1, EV_TIMEOUT, &ContextImpl::tickSearchS, this)) - ,initialSearcher(__FILE__, __LINE__, - event_new(tcp_loop.base, -1, EV_TIMEOUT, &ContextImpl::initialSearchS, this)) - ,manager(UDPManager::instance(effective.shareUDP())) - ,beaconCleaner(__FILE__, __LINE__, - event_new(manager.loop().base, -1, EV_TIMEOUT|EV_PERSIST, &ContextImpl::tickBeaconCleanS, this)) - ,cacheCleaner(__FILE__, __LINE__, - event_new(tcp_loop.base, -1, EV_TIMEOUT|EV_PERSIST, &ContextImpl::cacheCleanS, this)) - ,nsChecker(__FILE__, __LINE__, - event_new(tcp_loop.base, -1, EV_TIMEOUT|EV_PERSIST, &ContextImpl::onNSCheckS, this)) + : ifmap(IfaceMap::instance()), + effective([conf]() -> Config { + Config eff(conf); + eff.expand(); + return eff; + }()), + caMethod(buildCAMethod()), + searchTx4(AF_INET, SOCK_DGRAM, 0), + searchTx6(AF_INET6, SOCK_DGRAM, 0), + tcp_loop(tcp_loop), + searchRx4(__FILE__, __LINE__, event_new(tcp_loop.base, searchTx4.sock, EV_READ | EV_PERSIST, &ContextImpl::onSearchS, this)), + searchRx6(__FILE__, __LINE__, event_new(tcp_loop.base, searchTx6.sock, EV_READ | EV_PERSIST, &ContextImpl::onSearchS, this)), + searchTimer(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT, &ContextImpl::tickSearchS, this)), + initialSearcher(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT, &ContextImpl::initialSearchS, this)), + manager(UDPManager::instance(effective.shareUDP())), + beaconCleaner(__FILE__, __LINE__, event_new(manager.loop().base, -1, EV_TIMEOUT | EV_PERSIST, &ContextImpl::tickBeaconCleanS, this)), + cacheCleaner(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT | EV_PERSIST, &ContextImpl::cacheCleanS, this)), + nsChecker(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT | EV_PERSIST, &ContextImpl::onNSCheckS, this)) +#ifdef PVXS_ENABLE_OPENSSL + , + cert_event_timer(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT, doCertEventHandler, this)), + cert_validity_timer(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT, doCertStatusValidityEventhandler, this)), + file_watcher(filemon, {effective.tls_cert_filename, effective.tls_cert_password, effective.tls_private_key_filename, effective.tls_private_key_password}, + [this](bool enable) { + if (enable) + manager.loop().dispatch([this]() mutable { enableTls(); }); + else + manager.loop().dispatch([this]() mutable { disableTls(); }); + }) +#endif { +#ifdef PVXS_ENABLE_OPENSSL + if (conf.isTlsConfigured()) { + try { + tls_context = ossl::SSLContext::for_client(effective); + if (tls_context.has_cert) { + if (auto cert_ptr = getCert()) { + if (tls_context.status_check_disabled) { + Guard G(tls_context.lock); + tls_context.cert_is_valid = true; + log_warn_printf(setup, "Certificate status monitoring disabled by config: %s\n", effective.tls_cert_filename.c_str()); + } else { + try { + // Subscribe and set validity when the status is verified + auto ctx_cert = ossl_ptr(X509_dup(cert_ptr)); + cert_status_manager = certs::CertStatusManager::subscribe(std::move(ctx_cert), [this](certs::PVACertificateStatus status) { + Guard G(tls_context.lock); + auto was_good = current_status && current_status->isGood(); + current_status = std::make_shared(status); + auto is_good = current_status && current_status->isGood(); + UnGuard U(G); // Un-Guard to allow enabling and disabling TLS + if (is_good != was_good) { + manager.loop().dispatch([this, is_good]() { + if (is_good) enableTls(); + else disableTls(); + }); + } + }); + log_info_printf(setup, "TLS enabled for client pending certificate status: %s\n", effective.tls_cert_filename.c_str()); + } catch (certs::CertStatusNoExtensionException& e) { + Guard G(tls_context.lock); + tls_context.cert_is_valid = true; + log_info_printf(setup, "TLS enabled for client without status monitoring: %s\n", effective.tls_cert_filename.c_str()); + } + } + } + } + } catch (std::exception& e) { + log_warn_printf(setup, "TLS disabled for client: %s\n", e.what()); + } + } +#endif + searchBuckets.resize(nBuckets); std::set bcasts; - for(auto& addr : searchTx4.broadcasts()) { + for (auto& addr : searchTx4.broadcasts()) { addr.setPort(0u); bcasts.insert(addr); } @@ -554,12 +550,10 @@ ContextImpl::ContextImpl(const Config& conf, const evbase& tcp_loop) { auto any(SockAddr::any(searchTx4.af)); - if(bind(searchTx4.sock, &any->sa, any.size())) - throw std::runtime_error("Unable to bind random UDP port"); + if (bind(searchTx4.sock, &any->sa, any.size())) throw std::runtime_error("Unable to bind random UDP port"); socklen_t alen = any.capacity(); - if(getsockname(searchTx4.sock, &any->sa, &alen)) - throw std::runtime_error("Unable to readback random UDP port"); + if (getsockname(searchTx4.sock, &any->sa, &alen)) throw std::runtime_error("Unable to readback random UDP port"); searchRxPort = any.port(); @@ -567,56 +561,54 @@ ContextImpl::ContextImpl(const Config& conf, const evbase& tcp_loop) } { auto any(SockAddr::any(searchTx6.af, searchRxPort)); - if(bind(searchTx6.sock, &any->sa, any.size())) - throw std::runtime_error("Unable to bind random UDP6 port"); + if (bind(searchTx6.sock, &any->sa, any.size())) throw std::runtime_error("Unable to bind random UDP6 port"); } searchTx4.set_broadcast(true); searchTx4.enable_SO_RXQ_OVFL(); searchTx6.enable_SO_RXQ_OVFL(); - for(auto& addr : effective.addressList) { + for (auto& addr : effective.addressList) { SockEndpoint ep; try { - ep = SockEndpoint(addr, effective.udp_port); - }catch(std::exception& e){ + ep = SockEndpoint(addr, nullptr, effective.udp_port); + } catch (std::exception& e) { log_warn_printf(setup, "%s Ignoring malformed address %s\n", e.what(), addr.c_str()); continue; } - assert(ep.addr.family()==AF_INET || ep.addr.family()==AF_INET6); + assert(ep.addr.family() == AF_INET || ep.addr.family() == AF_INET6); // if !bcast and !mcast auto isucast = !ep.addr.isMCast(); - if(isucast && ep.addr.family()==AF_INET && bcasts.find(ep.addr)!=bcasts.end()) - isucast = false; + if (isucast && ep.addr.family() == AF_INET && bcasts.find(ep.addr) != bcasts.end()) isucast = false; - log_info_printf(io, "Searching to %s%s\n", std::string(SB()<start(); } - if(event_add(searchTimer.get(), &bucketInterval)) - log_err_printf(setup, "Error enabling search timer\n%s", ""); - if(event_add(searchRx4.get(), nullptr)) - log_err_printf(setup, "Error enabling search RX4\n%s", ""); - if(event_add(searchRx6.get(), nullptr)) - log_err_printf(setup, "Error enabling search RX6\n%s", ""); - if(event_add(beaconCleaner.get(), &beaconCleanInterval)) - log_err_printf(setup, "Error enabling beacon clean timer on\n%s", ""); - if(event_add(cacheCleaner.get(), &channelCacheCleanInterval)) - log_err_printf(setup, "Error enabling channel cache clean timer on\n%s", ""); - + if (event_add(searchTimer.get(), &bucketInterval)) log_err_printf(setup, "Error enabling search timer\n%s", ""); + if (event_add(searchRx4.get(), nullptr)) log_err_printf(setup, "Error enabling search RX4\n%s", ""); + if (event_add(searchRx6.get(), nullptr)) log_err_printf(setup, "Error enabling search RX6\n%s", ""); + if (event_add(beaconCleaner.get(), &beaconCleanInterval)) log_err_printf(setup, "Error enabling beacon clean timer on\n%s", ""); + if (event_add(cacheCleaner.get(), &channelCacheCleanInterval)) log_err_printf(setup, "Error enabling channel cache clean timer on\n%s", ""); +#ifdef PVXS_ENABLE_OPENSSL + if (event_add(cert_event_timer.get(), &statusIntervalShort)) log_err_printf(setup, "Error enabling cert status timer on\n%s", ""); + if (event_add(cert_validity_timer.get(), &statusIntervalShort)) log_err_printf(setup, "Error enabling cert status validity timer on\n%s", ""); +#endif state = Running; } ContextImpl::~ContextImpl() {} -void ContextImpl::startNS() -{ - if(nameServers.empty()) // vector size const after ctor, contents remain mutable +void ContextImpl::startNS() { + if (nameServers.empty()) // vector size const after ctor, contents remain mutable return; tcp_loop.call([this]() { // start connections to name servers - for(auto& ns : nameServers) { + for (auto& ns : nameServers) { const auto& serv = ns.first; - ns.second = Connection::build(shared_from_this(), serv); + ns.second = Connection::build(shared_from_this(), serv.addr, false +#ifdef PVXS_ENABLE_OPENSSL + , + serv.scheme == SockEndpoint::TLS +#endif + ); ns.second->nameserver = true; +#ifdef PVXS_ENABLE_OPENSSL + log_debug_printf(io, "Connecting to nameserver %s%s\n", ns.second->peerName.c_str(), ns.second->isTLS ? " TLS" : ""); +#else log_debug_printf(io, "Connecting to nameserver %s\n", ns.second->peerName.c_str()); +#endif } - if(event_add(nsChecker.get(), &tcpNSCheckInterval)) - log_err_printf(setup, "Error enabling TCP search reconnect timer\n%s", ""); + if (event_add(nsChecker.get(), &tcpNSCheckInterval)) log_err_printf(setup, "Error enabling TCP search reconnect timer\n%s", ""); }); } -void ContextImpl::close() -{ +void ContextImpl::close() { log_debug_printf(setup, "context %p close\n", this); +#ifdef PVXS_ENABLE_OPENSSL + // Stop status subscription if enabled + if (cert_status_manager) { + cert_status_manager->unsubscribe(); + cert_status_manager.reset(); + } + + // Stop file watcher if enabled + if (file_watcher.isRunning()) { + file_watcher.stop(); + } +#endif + // terminate all active connections tcp_loop.call([this]() { - if(state == Stopped) - return; + if (state == Stopped) return; state = Stopped; (void)event_del(searchTimer.get()); @@ -679,15 +687,17 @@ void ContextImpl::close() (void)event_del(searchRx6.get()); (void)event_del(beaconCleaner.get()); (void)event_del(cacheCleaner.get()); - +#ifdef PVXS_ENABLE_OPENSSL + (void)event_del(cert_event_timer.get()); + (void)event_del(cert_validity_timer.get()); +#endif auto conns(std::move(connByAddr)); // explicitly break ref. loop of channel cache auto chans(std::move(chanByName)); - for(auto& pair : conns) { + for (auto& pair : conns) { auto conn = pair.second.lock(); - if(!conn) - continue; + if (!conn) continue; conn->cleanup(); } @@ -707,17 +717,15 @@ void ContextImpl::close() manager.sync(); } -void ContextImpl::poke() -{ +void ContextImpl::poke() { { Guard G(pokeLock); - if(nPoked) - return; + if (nPoked) return; epicsTimeStamp now{}; double age = -1.0; - if(epicsTimeGetCurrent(&now) || (age=epicsTimeDiffInSeconds(&now, &lastPoke))= beaconTrackLimit) { + if (it == beaconTrack.end()) { + if (beaconTrack.size() >= beaconTrackLimit) { // Overloaded. Assume that some server is in a fast restart loop. // Ignore it, and continue tracking other/older servers. - log_debug_printf(beacon, "Tracking too many beacons, ignoring %s\n", - std::string(SB()<second); - if(action==Update && (cur.guid!=msg.guid || cur.peerVersion!=msg.peerVersion)) { + if (action == Update && (cur.guid != msg.guid || cur.peerVersion != msg.peerVersion)) { action = Change; log_debug_printf(beacon, "Update server %s\n", - std::string(SB()< "<first.second, - it->first.first.tostring(), - cur.guid, - now - }); + std::string(SB() << msg.src << " : " << msg.server << '/' << msg.proto << " " << cur.guid << '/' << (unsigned)cur.peerVersion << " -> " + << msg.guid << '/' << (unsigned)msg.peerVersion) + .c_str()); + + serverEvent(Discovered{Discovered::Timeout, cur.peerVersion, msg.src.tostring(), it->first.second, it->first.first.tostring(), cur.guid, now}); } cur.guid = msg.guid; @@ -802,28 +797,19 @@ void ContextImpl::onBeacon(const UDPManager::Beacon& msg) // could see beacons reach us from multiple interfaces. cur.sender = msg.src; - if(action!=Update) { - if(action==New) - log_debug_printf(beacon, "New server %s\n", - std::string(SB()< chan; { auto it = self.chanByCID.find(id); - if(it==self.chanByCID.end()) - continue; + if (it == self.chanByCID.end()) continue; chan = it->second.lock(); - if(!chan) - continue; + if (!chan) continue; } log_debug_printf(io, "Search reply for %s\n", chan->name.c_str()); - if(chan->state==Channel::Searching) { + if (chan->state == Channel::Searching) { chan->guid = guid; chan->replyAddr = serv; - chan->conn = Connection::build(self.shared_from_this(), serv); +#ifdef PVXS_ENABLE_OPENSSL + chan->conn = Connection::build(self.shared_from_this(), serv, false, isTLS); +#else + chan->conn = Connection::build(self.shared_from_this(), serv, false); +#endif chan->conn->pending[chan->cid] = chan; chan->state = Channel::Connecting; chan->conn->createChannels(); - } else if(chan->guid!=guid) { - log_err_printf(duppv, "Duplicate PV name %s from %s and %s\n", - chan->name.c_str(), - chan->replyAddr.tostring().c_str(), - serv.tostring().c_str()); + } else if (chan->guid != guid) { + log_err_printf(duppv, "Duplicate PV name %s from %s and %s\n", chan->name.c_str(), chan->replyAddr.tostring().c_str(), serv.tostring().c_str()); } } - } -bool ContextImpl::onSearch(evutil_socket_t fd) -{ +bool ContextImpl::onSearch(evutil_socket_t fd) { searchMsg.resize(0x10000); SockAddr src; - recvfromx rx{fd, (char*)&searchMsg[0], searchMsg.size()-1, &src}; + recvfromx rx{fd, (char*)&searchMsg[0], searchMsg.size() - 1, &src}; const int nrx = rx.call(); - if(nrx>=0 && rx.ndrop!=0 && prevndrop!=rx.ndrop) { + if (nrx >= 0 && rx.ndrop != 0 && prevndrop != rx.ndrop) { log_debug_printf(io, "UDP search reply buffer overflow %u -> %u\n", unsigned(prevndrop), unsigned(rx.ndrop)); prevndrop = rx.ndrop; } - if(nrx<0) { + if (nrx < 0) { int err = evutil_socket_geterror(fd); - if(err==SOCK_EWOULDBLOCK || err==EAGAIN || err==SOCK_EINTR) { + if (err == SOCK_EWOULDBLOCK || err == EAGAIN || err == SOCK_EINTR) { // nothing to do here } else { - log_warn_printf(io, "UDP search RX Error on : %s\n", - evutil_socket_error_to_string(err)); + log_warn_printf(io, "UDP search RX Error on : %s\n", evutil_socket_error_to_string(err)); } - return false; // wait for more I/O - + return false; // wait for more I/O } FixedBuf M(true, searchMsg.data(), nrx); Header head{}; - from_wire(M, head); // overwrites M.be + from_wire(M, head); // overwrites M.be - if(!M.good() || (head.flags&(pva_flags::Control|pva_flags::SegMask))) { + if (!M.good() || (head.flags & (pva_flags::Control | pva_flags::SegMask))) { // UDP packets can't contain control messages, or use segmentation log_hex_printf(io, Level::Debug, &searchMsg[0], nrx, "Ignore UDP message from %s\n", src.tostring().c_str()); @@ -956,60 +941,54 @@ bool ContextImpl::onSearch(evutil_socket_t fd) log_hex_printf(io, Level::Debug, &searchMsg[0], nrx, "UDP search Rx %d from %s\n", nrx, src.tostring().c_str()); - if(head.len > M.size() && M.good()) { + if (head.len > M.size() && M.good()) { log_info_printf(io, "UDP ignore header truncated%s", "\n"); return true; } - if(head.cmd==CMD_SEARCH_RESPONSE) { + if (head.cmd == CMD_SEARCH_RESPONSE) { procSearchReply(*this, src, head.version, M, false); } else { M.fault(__FILE__, __LINE__); } - if(!M.good()) { - log_hex_printf(io, Level::Err, &searchMsg[0], nrx, - "%s:%d Invalid search reply %d from %s\n", - M.file(), M.line(), nrx, src.tostring().c_str()); + if (!M.good()) { + log_hex_printf(io, Level::Err, &searchMsg[0], nrx, "%s:%d Invalid search reply %d from %s\n", M.file(), M.line(), nrx, src.tostring().c_str()); } return true; } -void Connection::handle_SEARCH_RESPONSE() -{ +void Connection::handle_SEARCH_RESPONSE() { EvInBuf M(peerBE, segBuf.get(), 16); procSearchReply(*context, peerAddr, peerVersion, M, true); - if(!M.good()) { - log_crit_printf(io, "%s:%d Server %s sends invalid SEARCH_RESPONSE. Disconnecting...\n", - M.file(), M.line(), peerName.c_str()); + if (!M.good()) { + log_crit_printf(io, "%s:%d Server %s sends invalid SEARCH_RESPONSE. Disconnecting...\n", M.file(), M.line(), peerName.c_str()); bev.reset(); } } -void ContextImpl::onSearchS(evutil_socket_t fd, short evt, void *raw) -{ +void ContextImpl::onSearchS(evutil_socket_t fd, short evt, void* raw) { try { log_debug_printf(io, "UDP search Rx event %x\n", evt); - if(!(evt&EV_READ)) - return; + if (!(evt & EV_READ)) return; // limit number of packets processed before going back to the reactor unsigned i; const unsigned limit = 40; - for(i=0; i(raw)->onSearch(fd); i++) {} + for (i = 0; i < limit && static_cast(raw)->onSearch(fd); i++) { + } log_debug_printf(io, "UDP search processed %u/%u\n", i, limit); - }catch(std::exception& e){ + } catch (std::exception& e) { log_exc_printf(io, "Unhandled error in search Rx callback: %s\n", e.what()); } } -void ContextImpl::tickSearch(SearchKind kind, bool poked) -{ +void ContextImpl::tickSearch(SearchKind kind, bool poked) { // If kind == SearchKind::discover, then this is a discovery ping. // these are really empty searches with must-reply set. // So if !discover, then we should not be modifying any internal state @@ -1019,24 +998,23 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) // channels in the searchBuckets. auto idx = currentBucket; - if(kind == SearchKind::check) - currentBucket = (currentBucket+1u)%searchBuckets.size(); + if (kind == SearchKind::check) currentBucket = (currentBucket + 1u) % searchBuckets.size(); log_debug_printf(io, "Search tick %zu\n", idx); - decltype (searchBuckets)::value_type bucket; + decltype(searchBuckets)::value_type bucket; if (kind == SearchKind::initial) { initialSearchBucket.swap(bucket); - } else if(kind == SearchKind::check) { + } else if (kind == SearchKind::check) { searchBuckets[idx].swap(bucket); } - while(!bucket.empty() || kind == SearchKind::discover) { + while (!bucket.empty() || kind == SearchKind::discover) { // when 'discover' we only loop once searchMsg.resize(0x10000); FixedBuf M(true, searchMsg.data(), searchMsg.size()); - M.skip(8, __FILE__, __LINE__); // fill in header after body length known + M.skip(8, __FILE__, __LINE__); // fill in header after body length known // searchSequenceID to_wire(M, search_seq); @@ -1044,8 +1022,7 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) // flags and reserved. // initially flags[7] is cleared (bcast) auto pflags = M.save(); - to_wire(M, uint8_t(kind == SearchKind::discover ? - pva_search_flags::MustReply : 0u)); // must-reply to discovery, ignore regular negative search + to_wire(M, uint8_t(kind == SearchKind::discover ? pva_search_flags::MustReply : 0u)); // must-reply to discovery, ignore regular negative search to_wire(M, uint8_t(0u)); to_wire(M, uint16_t(0u)); @@ -1058,9 +1035,16 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) auto pport = M.save(); to_wire(M, uint16_t(searchRxPort)); - if(kind == SearchKind::discover) { + if (kind == SearchKind::discover) { to_wire(M, uint8_t(0u)); +#ifdef PVXS_ENABLE_OPENSSL + } else if (tls_context) { + to_wire(M, uint8_t(2u)); + to_wire(M, "tls"); + to_wire(M, "tcp"); +#endif + } else { to_wire(M, uint8_t(1u)); to_wire(M, "tcp"); @@ -1072,11 +1056,11 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) M.skip(2u, __FILE__, __LINE__); bool payload = false; - while(!bucket.empty()) { + while (!bucket.empty()) { assert(kind != SearchKind::discover); auto chan = bucket.front().lock(); - if(!chan || chan->state!=Channel::Searching) { + if (!chan || chan->state != Channel::Searching) { bucket.pop_front(); continue; } @@ -1085,15 +1069,15 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) to_wire(M, uint32_t(chan->cid)); to_wire(M, chan->name); - if(!M.good()) { + if (!M.good()) { // some absurdly long PV name? log_err_printf(io, "PV name exceeds search buffer: '%s'\n", chan->name.c_str()); // drop it on the floor bucket.pop_front(); continue; - } else if(size_t(M.save() - searchMsg.data()) > maxSearchPayload) { - if(payload) { + } else if (size_t(M.save() - searchMsg.data()) > maxSearchPayload) { + if (payload) { // other names did fit, defer this one to the next packet M.restore(save); break; @@ -1109,31 +1093,26 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) count++; size_t ninc = 0u; - if(kind==SearchKind::check && !poked) - ninc = chan->nSearch = std::min(searchBuckets.size(), chan->nSearch+1u); - auto next = (idx + ninc)%searchBuckets.size(); - auto nextnext = (next + 1u)%searchBuckets.size(); + if (kind == SearchKind::check && !poked) ninc = chan->nSearch = std::min(searchBuckets.size(), chan->nSearch + 1u); + auto next = (idx + ninc) % searchBuckets.size(); + auto nextnext = (next + 1u) % searchBuckets.size(); // try to smooth out UDP bcast load by waiting one extra tick { auto nextN = searchBuckets[next].size(); auto nextnextN = searchBuckets[nextnext].size(); - if(nextN > nextnextN && (nextN-nextnextN > 100u)) - next = nextnext; + if (nextN > nextnextN && (nextN - nextnextN > 100u)) next = nextnext; } auto& nextBucket = searchBuckets[next]; - nextBucket.splice(nextBucket.end(), - bucket, - bucket.begin()); + nextBucket.splice(nextBucket.end(), bucket, bucket.begin()); payload = true; } assert(M.good()); - if(!payload && kind != SearchKind::discover) - break; + if (!payload && kind != SearchKind::discover) break; { FixedBuf C(true, pcount, 2u); @@ -1142,12 +1121,12 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) size_t consumed = M.save() - searchMsg.data(); { FixedBuf H(true, searchMsg.data(), 8); - to_wire(H, Header{CMD_SEARCH, 0, uint32_t(consumed-8u)}); + to_wire(H, Header{CMD_SEARCH, 0, uint32_t(consumed - 8u)}); } - for(auto& pair : searchDest) { - auto& dest = pair.first.addr.family()==AF_INET ? searchTx4 : searchTx6; + for (auto& pair : searchDest) { + auto& dest = pair.first.addr.family() == AF_INET ? searchTx4 : searchTx6; - if(pair.second) { + if (pair.second) { *pflags |= pva_search_flags::Unicast; } else { @@ -1156,63 +1135,53 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) dest.mcast_prep_sendto(pair.first); } - int ntx = sendto(dest.sock, (char*)searchMsg.data(), consumed, 0, - &pair.first.addr->sa, pair.first.addr.size()); + int ntx = sendto(dest.sock, (char*)searchMsg.data(), consumed, 0, &pair.first.addr->sa, pair.first.addr.size()); - if(ntx<0) { + if (ntx < 0) { int err = evutil_socket_geterror(dest.sock); auto lvl = Level::Warn; - if(err==EINTR || err==EPERM) - lvl = Level::Debug; - log_printf(io, lvl, "Search tx %s error (%d) %s\n", - pair.first.addr.tostring().c_str(), err, evutil_socket_error_to_string(err)); + if (err == EINTR || err == EPERM) lvl = Level::Debug; + log_printf(io, lvl, "Search tx %s error (%d) %s\n", pair.first.addr.tostring().c_str(), err, evutil_socket_error_to_string(err)); - } else if(unsigned(ntx)ready || !serv->connection()) - continue; + if (!serv->ready || !serv->connection()) continue; auto tx = bufferevent_get_output(serv->connection()); // arbitrarily skip searching if TX buffer is too full // TODO: configure limit? - if(evbuffer_get_length(tx) > 64*1024u) - continue; + if (evbuffer_get_length(tx) > 64 * 1024u) continue; (void)evbuffer_add(tx, (char*)searchMsg.data(), consumed); // fail silently, will retry } - if(kind == SearchKind::discover) - break; + if (kind == SearchKind::discover) break; } } -void ContextImpl::tickSearchS(evutil_socket_t fd, short evt, void *raw) -{ +void ContextImpl::tickSearchS(evutil_socket_t fd, short evt, void* raw) { auto self(static_cast(raw)); try { bool poke = false; { Guard G(self->pokeLock); - if(self->nPoked) { + if (self->nPoked) { poke = true; self->nPoked--; } @@ -1220,116 +1189,106 @@ void ContextImpl::tickSearchS(evutil_socket_t fd, short evt, void *raw) self->tickSearch(SearchKind::check, poke); - if(event_add(self->searchTimer.get(), poke ? &bucketIntervalFast : &bucketInterval)) + if (event_add(self->searchTimer.get(), poke ? &bucketIntervalFast : &bucketInterval)) log_err_printf(setup, "Error re-enabling search timer on\n%s", ""); - }catch(std::exception& e){ + } catch (std::exception& e) { log_exc_printf(io, "Unhandled error in search timer callback: %s\n", e.what()); } } -void ContextImpl::initialSearchS(evutil_socket_t fd, short evt, void *raw) -{ +void ContextImpl::initialSearchS(evutil_socket_t fd, short evt, void* raw) { auto self(static_cast(raw)); try { self->initialSearchScheduled = false; self->tickSearch(SearchKind::initial, false); - }catch(std::exception& e){ + } catch (std::exception& e) { log_exc_printf(io, "Unhandled error in initial search callback: %s\n", e.what()); } } -void ContextImpl::tickBeaconClean() -{ +void ContextImpl::tickBeaconClean() { epicsTimeStamp now; epicsTimeGetCurrent(&now); Guard G(pokeLock); auto it = beaconTrack.begin(); - while(it!=beaconTrack.end()) { + while (it != beaconTrack.end()) { auto cur = it++; double age = epicsTimeDiffInSeconds(&now, &cur->second.time); - if(age < -15.0 || age > 2*beaconCleanInterval.tv_sec) { + if (age < -15.0 || age > 2 * beaconCleanInterval.tv_sec) { log_debug_printf(io, "%s\n", - std::string(SB()<<" Lost server "<second.guid - <<' '<first.second<<'/'<first.first).c_str()); - - serverEvent(Discovered{Discovered::Timeout, - cur->second.peerVersion, - "", // no associated Beacon - cur->first.second, - cur->first.first.tostring(), - cur->second.guid, - now - }); + std::string(SB() << " Lost server " << cur->second.guid << ' ' << cur->first.second << '/' << cur->first.first).c_str()); + + serverEvent(Discovered{Discovered::Timeout, cur->second.peerVersion, + "", // no associated Beacon + cur->first.second, cur->first.first.tostring(), cur->second.guid, now}); beaconTrack.erase(cur); } } } -void ContextImpl::tickBeaconCleanS(evutil_socket_t fd, short evt, void *raw) -{ +void ContextImpl::tickBeaconCleanS(evutil_socket_t fd, short evt, void* raw) { try { static_cast(raw)->tickBeaconClean(); - }catch(std::exception& e){ + } catch (std::exception& e) { log_exc_printf(io, "Unhandled error in beacon cleaner timer callback: %s\n", e.what()); } } -void ContextImpl::onNSCheck() -{ - for(auto& ns : nameServers) { - if(ns.second && ns.second->state != ConnBase::Disconnected) // hold-off, connecting, or connected +void ContextImpl::onNSCheck() { + for (auto& ns : nameServers) { + if (ns.second && ns.second->state != ConnBase::Disconnected) // hold-off, connecting, or connected continue; - ns.second = Connection::build(shared_from_this(), ns.first); + ns.second = Connection::build(shared_from_this(), ns.first.addr, false +#ifdef PVXS_ENABLE_OPENSSL + , + ns.first.scheme == SockEndpoint::TLS +#endif + ); ns.second->nameserver = true; log_debug_printf(io, "Reconnecting nameserver %s\n", ns.second->peerName.c_str()); } } -void ContextImpl::onNSCheckS(evutil_socket_t fd, short evt, void *raw) -{ +void ContextImpl::onNSCheckS(evutil_socket_t fd, short evt, void* raw) { try { static_cast(raw)->onNSCheck(); - }catch(std::exception& e){ + } catch (std::exception& e) { log_exc_printf(io, "Unhandled error in TCP nameserver timer callback: %s\n", e.what()); } } -void ContextImpl::cacheClean(const std::string& name, Context::cacheAction action) -{ - auto next(chanByName.begin()), - end(chanByName.end()); +void ContextImpl::cacheClean(const std::string& name, Context::cacheAction action) { + auto next(chanByName.begin()), end(chanByName.end()); - while(next!=end) { + while (next != end) { auto cur(next++); - if(!name.empty() && cur->first.first!=name) + if (!name.empty() && cur->first.first != name) continue; - else if(action!=Context::Clean || cur->second.use_count()<=1) { + else if (action != Context::Clean || cur->second.use_count() <= 1) { cur->second->garbage = true; - if(action==Context::Clean && !cur->second->garbage) { + if (action == Context::Clean && !cur->second->garbage) { // mark for next sweep - log_debug_printf(setup, "Chan GC mark '%s':'%s'\n", - cur->first.first.c_str(), cur->first.second.c_str()); + log_debug_printf(setup, "Chan GC mark '%s':'%s'\n", cur->first.first.c_str(), cur->first.second.c_str()); } else { - log_debug_printf(setup, "Chan GC sweep '%s':'%s'\n", - cur->first.first.c_str(), cur->first.second.c_str()); + log_debug_printf(setup, "Chan GC sweep '%s':'%s'\n", cur->first.first.c_str(), cur->first.second.c_str()); auto trash(std::move(cur->second)); // explicitly break ref. loop of channel cache chanByName.erase(cur); - if(action==Context::Disconnect) { + if (action == Context::Disconnect) { trash->disconnect(trash); } } @@ -1337,26 +1296,145 @@ void ContextImpl::cacheClean(const std::string& name, Context::cacheAction actio } } -void ContextImpl::cacheCleanS(evutil_socket_t fd, short evt, void *raw) -{ +void ContextImpl::cacheCleanS(evutil_socket_t fd, short evt, void* raw) { try { static_cast(raw)->cacheClean(std::string(), Context::Clean); static_cast(raw)->tickBeaconClean(); - }catch(std::exception& e){ + } catch (std::exception& e) { log_exc_printf(io, "Unhandled error in beacon cleaner timer callback: %s\n", e.what()); } } +#ifndef PVXS_ENABLE_OPENSSL +Context::Pvt::Pvt(const Config& conf) : loop("PVXCTCP", epicsThreadPriorityCAServerLow), impl(std::make_shared(conf, loop.internal())) {} +#else + +DO_CERT_EVENT_HANDLER(ContextImpl, io) +DO_CERT_STATUS_VALIDITY_EVENT_HANDLER(ContextImpl, getStatus) + +void Context::reconfigure(const Config& newconf) { + if (!pvt) throw std::logic_error("NULL Context"); + +#ifdef PVXS_ENABLE_OPENSSL + if (newconf.isTlsConfigured()) { + Guard G(pvt->impl->tls_context.lock); + pvt->impl->tls_context.has_cert = false; // Force reload of context from cert + UnGuard U(G); + pvt->impl->manager.loop().call([this, &newconf]() mutable { pvt->impl->enableTls(newconf); }); + pvt->impl->manager.loop().sync(); + } +#else + pvt->impl->manager.loop().sync(); +#endif +} + +/** + * @brief Enable TLS with the optional config if provided + * @param new_config optional config (check the is_initialized flag to see if its blank or not) + */ +void ContextImpl::enableTls(const Config& new_config) { + // If already valid then don't do anything + if (tls_context.has_cert && tls_context.cert_is_valid) return; + + log_debug_printf(watcher, "Enabling TLS. Certificate file is %s\n", effective.tls_cert_filename.c_str()); + try { + Guard G(tls_context.lock); // We can lock here because `for_client` will create a completely different tls_context + + // if we don't have a cert then get a new one + if (!tls_context.has_cert) { + log_debug_printf(watcher, "Creating a new TLS context using %s\n", effective.tls_cert_filename.c_str()); + auto new_context = ossl::SSLContext::for_client(new_config.is_initialized ? new_config : effective); + + // If unsuccessful in getting cert then don't do anything + if (!new_context.has_cert) { + log_debug_printf(watcher, "Failed to create new TLS context: TLS disabled: %s\n", effective.tls_cert_filename.c_str()); + return; + } + tls_context = new_context; + effective = (new_config.is_initialized ? new_config : effective); + } + + // Subscribe to certificate status if not already subscribed + if (!cert_status_manager && !tls_context.status_check_disabled) { + log_debug_printf(watcher, "Subscribing to certificate status for %s\n", effective.tls_cert_filename.c_str()); + subscribeToCertStatus(); // Sets the cert_status_manager if successfully subscribes + } + + log_debug_printf(watcher, "Closing %zu connections to replace with TLS ones\n", connByAddr.size()); + auto conns(std::move(connByAddr)); + for (auto& pair : conns) { + auto conn = pair.second.lock(); + if (conn) { + conn->cleanup(); + } + } + conns.clear(); + + // Set callback for when this status' validity ends + if (!tls_context.status_check_disabled) { + log_debug_printf(watcher, "Starting certificate status validity timer after receiving status for %s\n", effective.tls_cert_filename.c_str()); + startStatusValidityTimer(); + } + + tls_context.cert_is_valid = true; + log_info_printf(watcher, "TLS enabled for client due to a certificate status change%s\n", ""); + } catch (std::exception& e) { + log_debug_printf(watcher, "%s: TLS remains disabled for client: with %s\n", e.what(), effective.tls_cert_filename.c_str()); + } +} + +/** + * @brief Called to disable TLS - if TLS is not enabled then this will do nothin. It is idempotent + */ +void ContextImpl::disableTls() { + log_debug_printf(watcher, "Disabling TLS%s\n", ""); + Guard G(tls_context.lock); + if (cert_status_manager) { + // Stop subscribing to status + log_debug_printf(watcher, "Disable TLS: Stopping certificate monitor%s\n", ""); + cert_status_manager.reset(); + } + + // Skip if TLS is already disabled + if (!tls_context.has_cert || !tls_context.cert_is_valid) return; + + // Remove all tls connections so that they will reconnect as tcp + std::vector > to_cleanup; + // Collect tls connections to clean-up + for (auto& pair : connByAddr) { + auto conn = pair.second.lock(); + if (conn && conn->isTLS) { + to_cleanup.push_back(pair.second); + } + } + + log_debug_printf(watcher, "Closing %zu TLS connections to replace with TCP ones\n", to_cleanup.size()); + // Clean them up + for (auto& weak_conn : to_cleanup) { + auto conn = weak_conn.lock(); + if (conn) { + conn->cleanup(); + } + } + + tls_context.cert_is_valid = false; + tls_context.has_cert = false; + log_warn_printf(watcher, "TLS disabled for client%s\n", ""); +} + +FILE_EVENT_CALLBACK(ContextImpl) +GET_CERT(ContextImpl) +START_STATUS_VALIDITY_TIMER(ContextImpl, manager.loop()) +SUBSCRIBE_TO_CERT_STATUS(ContextImpl, CertificateStatus, manager.loop()) + Context::Pvt::Pvt(const Config& conf) - :loop("PVXCTCP", epicsThreadPriorityCAServerLow) - ,impl(std::make_shared(conf, loop.internal())) + : loop("PVXCTCP", epicsThreadPriorityCAServerLow), + impl(std::make_shared(conf, loop.internal())) +#endif {} -Context::Pvt::~Pvt() -{ - impl->close(); -} +Context::Pvt::~Pvt() { impl->close(); } -} // namespace client +} // namespace client -} // namespace pvxs +} // namespace pvxs diff --git a/src/clientconn.cpp b/src/clientconn.cpp index 30af209df..9becda763 100644 --- a/src/clientconn.cpp +++ b/src/clientconn.cpp @@ -7,22 +7,90 @@ #include #include + +#ifdef PVXS_ENABLE_OPENSSL +#include "certstatusmanager.h" +#endif + #include "clientimpl.h" namespace pvxs { + +#ifdef PVXS_ENABLE_OPENSSL +namespace { +DEFINE_LOGGER(stapling, "pvxs.stapling"); + +/** + * @brief A callback function for handling OCSP (Online Certificate Status Protocol) responses in an SSL context. + * + * This function is intended to be used as a client-side callback for validating the status of certificates using OCSP. + * The implementation of this function should handle the retrieval and processing of OCSP responses, potentially affecting + * the SSL connection based on the certificate validity determined by the OCSP response. + * + * @param ctx A pointer to the SSL context where the callback is set. + * @param arg An optional user-defined argument that can be passed to the callback. (ignored) + * + * @return Typically returns an integer value indicating the SSL_TLSEXT_ERR_OK, SSL_TLSEXT_ERR_ALERT_WARNING, + * or SSL_TLSEXT_ERR_ALERT_FATAL of the OCSP validation. + */ +static int clientOCSPCallback(SSL* ctx, ossl::SSLContext* tls_context) { + X509 *peer_cert = SSL_get_peer_certificate(ctx); + auto peer_status = tls_context->ex_data(); + try { + uint8_t* ocsp_response_ptr; + auto len = SSL_get_tlsext_status_ocsp_resp(ctx, &ocsp_response_ptr); + + if (!ocsp_response_ptr || len == -1) { + log_debug_printf(stapling, "No Stapled OCSP response found by %s\n", "client"); + if (peer_status) + peer_status->setCachedPeerStatus(peer_cert, certs::UnknownCertificateStatus()); + return SSL_TLSEXT_ERR_ALERT_FATAL; + } + const shared_array ocsp_bytes(ocsp_response_ptr, len); + auto status = (certs::CertificateStatus)(certs::OCSPStatus(ocsp_bytes)); + if (peer_status) + peer_status->setCachedPeerStatus(peer_cert, status); + if (status.isGood()) { + log_debug_printf(stapling, "Client OCSP stapled response is: %s\n", status.ocsp_status.s.c_str()); + log_debug_printf(stapling, "Client OCSP stapled status date: %s\n", status.status_date.s.c_str()); + log_debug_printf(stapling, "Client OCSP stapled status valid until: %s\n", status.status_valid_until_date.s.c_str()); + log_debug_printf(stapling, "Client OCSP stapled revocation date: %s\n", status.revocation_date.s.c_str()); + return SSL_TLSEXT_ERR_OK; + } else { + log_err_printf(stapling, "OCSP stapled response is: %s\n", status.ocsp_status.s.c_str()); + return SSL_TLSEXT_ERR_ALERT_FATAL; + } + } catch (std::exception& e) { + if (peer_status) + peer_status->setCachedPeerStatus(peer_cert, certs::UnknownCertificateStatus()); + log_err_printf(stapling, "Stapled OCSP response: %s\n", e.what()); + } + return SSL_TLSEXT_ERR_ALERT_FATAL; +} + +} // namespace +#endif + namespace client { -DEFINE_LOGGER(io, "pvxs.client.io"); -DEFINE_LOGGER(connsetup, "pvxs.tcp.setup"); +DEFINE_LOGGER(io, "pvxs.cli.io"); +DEFINE_LOGGER(connsetup, "pvxs.tcp.init"); DEFINE_LOGGER(remote, "pvxs.remote.log"); Connection::Connection(const std::shared_ptr& context, const SockAddr& peerAddr, - bool reconn) + bool reconn +#ifdef PVXS_ENABLE_OPENSSL + , bool isTLS +#endif + ) :ConnBase (true, context->effective.sendBE(), nullptr, peerAddr) ,context(context) +#ifdef PVXS_ENABLE_OPENSSL + ,isTLS(isTLS) +#endif ,echoTimer(__FILE__, __LINE__, event_new(context->tcp_loop.base, -1, EV_TIMEOUT|EV_PERSIST, &tickEchoS, this)) { @@ -44,39 +112,89 @@ Connection::~Connection() cleanup(); } +#ifdef PVXS_ENABLE_OPENSSL +std::shared_ptr Connection::build(const std::shared_ptr& context, + const SockAddr& serv, bool reconn, bool tls) +#else std::shared_ptr Connection::build(const std::shared_ptr& context, const SockAddr& serv, bool reconn) +#endif { if(context->state!=ContextImpl::Running) throw std::logic_error("Context close()d"); +#ifdef PVXS_ENABLE_OPENSSL + auto pair(std::make_pair(serv, tls)); + std::shared_ptr ret; + auto it = context->connByAddr.find(pair); + if (it == context->connByAddr.end() || !(ret = it->second.lock())) { + context->connByAddr[pair] = ret = std::make_shared(context, serv, reconn, tls); + } +#else std::shared_ptr ret; auto it = context->connByAddr.find(serv); if(it==context->connByAddr.end() || !(ret = it->second.lock())) { context->connByAddr[serv] = ret = std::make_shared(context, serv, reconn); } +#endif return ret; } -void Connection::startConnecting() -{ +void Connection::startConnecting() { assert(!this->bev); - decltype(this->bev) bev(__FILE__, __LINE__, - bufferevent_socket_new(context->tcp_loop.base, -1, - BEV_OPT_CLOSE_ON_FREE|BEV_OPT_DEFER_CALLBACKS)); + decltype(this->bev) bev(__FILE__, __LINE__, bufferevent_socket_new(context->tcp_loop.base, -1, BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS)); + +#ifdef PVXS_ENABLE_OPENSSL + if (isTLS) { + auto ctx(SSL_new(context->tls_context.ctx)); + if (!ctx) throw std::runtime_error("SSL_new"); + + // w/ BEV_OPT_CLOSE_ON_FREE calls SSL_free() on error + bev.reset(bufferevent_openssl_socket_new(context->tcp_loop.base, -1, ctx, BUFFEREVENT_SSL_CONNECTING, BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS)); + + // added with libevent 2.2.1-alpha + //(void)bufferevent_ssl_set_flags(bev.get(), BUFFEREVENT_SSL_DIRTY_SHUTDOWN); + // deprecated, but not yet removed + bufferevent_openssl_set_allow_dirty_shutdown(bev.get(), 1); + + // If stapling is not disabled + if (!context->tls_context.stapling_disabled) { + // And client was not previously set to request the stapled OCSP Response + if (SSL_get_tlsext_status_type(ctx) != -1) { + // Then enable OCSP status request extension + log_debug_printf(stapling, "Client OCSP Stapling: Setting up request%s\n", ""); + if (SSL_set_tlsext_status_type(ctx, TLSEXT_STATUSTYPE_ocsp)) { + log_debug_printf(stapling, "Client OCSP Stapling: requested type set for stapling%s\n", ""); + } else { + throw ossl::SSLError("Client OCSP Stapling: Error enabling stapling"); + } + // Set the callback + SSL_CTX_set_tlsext_status_cb(context->tls_context.ctx, clientOCSPCallback); + // And send the tls context as the parameter to the callabck + SSL_CTX_set_tlsext_status_arg(context->tls_context.ctx, &context->tls_context); + } + } + } else +#endif + { + bev.reset(bufferevent_socket_new(context->tcp_loop.base, -1, BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS)); + } bufferevent_setcb(bev.get(), &bevReadS, nullptr, &bevEventS, this); timeval tmo(totv(context->effective.tcpTimeout)); bufferevent_set_timeouts(bev.get(), &tmo, &tmo); - if(bufferevent_socket_connect(bev.get(), const_cast(&peerAddr->sa), peerAddr.size())) - throw std::runtime_error("Unable to begin connecting"); + if (bufferevent_socket_connect(bev.get(), const_cast(&peerAddr->sa), peerAddr.size())) throw std::runtime_error("Unable to begin connecting"); connect(std::move(bev)); +#ifdef PVXS_ENABLE_OPENSSL + log_debug_printf(io, "Connecting to %s, RX readahead %zu%s\n", peerName.c_str(), readahead, isTLS ? " TLS" : ""); +#else log_debug_printf(io, "Connecting to %s, RX readahead %zu\n", peerName.c_str(), readahead); +#endif } void Connection::createChannels() @@ -130,11 +248,36 @@ void Connection::sendDestroyRequest(uint32_t sid, uint32_t ioid) void Connection::bevEvent(short events) { +#ifdef PVXS_ENABLE_OPENSSL + if ((events & (BEV_EVENT_ERROR | BEV_EVENT_EOF)) && isTLS && bev) { + while (auto err = bufferevent_get_openssl_error(bev.get())) { + auto error_reason = ERR_reason_error_string(err); + if (error_reason) log_err_printf(io, "Client: TLS Error (0x%lx) %s\n", err, ERR_reason_error_string(err)); + } + } +#endif ConnBase::bevEvent(events); // called Connection::cleanup() if(bev && (events&BEV_EVENT_CONNECTED)) { log_debug_printf(io, "Connected to %s\n", peerName.c_str()); + connTime = epicsTime::getCurrent(); + + auto peerCred(std::make_shared()); + peerCred->peer = peerName; +#ifdef PVXS_ENABLE_OPENSSL + peerCred->isTLS = isTLS; + + if (isTLS) { + auto ctx = bufferevent_openssl_get_ssl(bev.get()); + assert(ctx); + ossl::SSLContext::fill_credentials(*peerCred, ctx); + } else +#endif + { + peerCred->method = "anonymous"; + } + cred = std::move(peerCred); { // after async connect() to avoid winsock specific race. @@ -169,7 +312,11 @@ void Connection::cleanup() { ready = false; +#ifdef PVXS_ENABLE_OPENSSL + context->connByAddr.erase(std::make_pair(peerAddr, isTLS)); +#else context->connByAddr.erase(peerAddr); +#endif if(bev) bev.reset(); @@ -232,6 +379,11 @@ void Connection::handle_CONNECTION_VALIDATION() if(method=="ca" || (method=="anonymous" && selected!="ca")) selected = method; +#ifdef PVXS_ENABLE_OPENSSL + // Only validate as TLS if we have a valid certificate + else if (isTLS && method == "x509" && context->tls_context.has_cert && context->tls_context.cert_is_valid) + selected = method; +#endif } if(!M.good()) { @@ -316,7 +468,10 @@ void Connection::handle_CONNECTION_VALIDATED() sts.msg.empty() ? "" : " ", sts.msg.c_str()); } - ready = true; + // we are ready as long as we're not waiting for certificate status + ready = context->connectionCanProceed(); + if ( !ready ) + return; createChannels(); @@ -395,9 +550,10 @@ void Connection::handle_CREATE_CHANNEL() auto conns(chan->connectors); // copy list + struct Connected connEvt(peerName, connTime, cred); for(auto& conn : conns) { if(!conn->_connected.exchange(true, std::memory_order_relaxed) && conn->_onConn) - conn->_onConn(); + conn->_onConn(connEvt); } } } @@ -505,6 +661,5 @@ void Connection::tickEchoS(evutil_socket_t fd, short evt, void *raw) log_exc_printf(io, "Unhandled error in echo timer callback: %s\n", e.what()); } } - } // namespace client } // namespace pvxs diff --git a/src/clientdiscover.cpp b/src/clientdiscover.cpp index 480a0dcab..7329da24a 100644 --- a/src/clientdiscover.cpp +++ b/src/clientdiscover.cpp @@ -9,8 +9,8 @@ #include "utilpvt.h" #include "clientimpl.h" -DEFINE_LOGGER(setup, "pvxs.client.setup"); -DEFINE_LOGGER(io, "pvxs.client.io"); +DEFINE_LOGGER(setup, "pvxs.cli.init"); +DEFINE_LOGGER(io, "pvxs.cli.io"); namespace pvxs { namespace client { diff --git a/src/clientget.cpp b/src/clientget.cpp index 19dee0a41..9b550ad9d 100644 --- a/src/clientget.cpp +++ b/src/clientget.cpp @@ -13,8 +13,8 @@ namespace pvxs { namespace client { -DEFINE_LOGGER(setup, "pvxs.client.setup"); -DEFINE_LOGGER(io, "pvxs.client.io"); +DEFINE_LOGGER(setup, "pvxs.cli.init"); +DEFINE_LOGGER(io, "pvxs.cli.io"); namespace detail { @@ -161,8 +161,8 @@ struct GPROp : public OperationBase done(std::move(result)); } catch(std::exception& e) { if(chan && chan->conn) - log_err_printf(io, "Server %s channel %s error in result cb : %s\n", - chan->conn->peerName.c_str(), chan->name.c_str(), e.what()); + log_err_printf(io, "Result Callback Error: Server %s channel %s\n", chan->conn->peerName.c_str(), chan->name.c_str()); + log_err_printf(io, "Result Callback Error: %s\n", e.what()); // keep first error (eg. from put builder) if(!result.error()) @@ -592,9 +592,8 @@ std::shared_ptr gpr_setup(const std::shared_ptr& context }, std::move(temp))); }); - context->tcp_loop.dispatch([internal, context, name, server]() { + context->tcp_loop.dispatch([context, internal, name, server]() { // on worker - try { internal->chan = Channel::build(context, name, server); @@ -604,6 +603,7 @@ std::shared_ptr gpr_setup(const std::shared_ptr& context internal->result = Result(std::current_exception()); internal->notify(); } + // on worker }); return external; diff --git a/src/clientimpl.h b/src/clientimpl.h index d91019192..0f3c15e0f 100644 --- a/src/clientimpl.h +++ b/src/clientimpl.h @@ -14,15 +14,24 @@ #include -#include "evhelper.h" +#include "certstatus.h" +#include "certstatusmanager.h" +#include "conn.h" #include "dataimpl.h" -#include "utilpvt.h" +#include "evhelper.h" +#include "ownedptr.h" +#include "p12filewatcher.h" #include "udp_collector.h" -#include "conn.h" +#include "utilpvt.h" namespace pvxs { namespace client { +class MonitorCreationException : public std::runtime_error { + public: + explicit MonitorCreationException(const std::string& message) : std::runtime_error(message) {} +}; + struct Channel; struct ContextImpl; @@ -50,6 +59,7 @@ struct OperationBase : public Operation Value result; bool done = false; std::shared_ptr waiter; + // TODO store the "wants-tls" so that we know what we can bounce when TLS is enabled in enableTls() OperationBase(operation_t op, const evbase& loop); virtual ~OperationBase(); @@ -83,6 +93,9 @@ struct RequestInfo { struct Connection final : public ConnBase, public std::enable_shared_from_this { const std::shared_ptr context; +#ifdef PVXS_ENABLE_OPENSSL + const bool isTLS; +#endif // While HoldOff, the time until re-connection // While Connected, periodic Echo @@ -102,17 +115,28 @@ struct Connection final : public ConnBase, public std::enable_shared_from_this cred; + INST_COUNTER(Connection); Connection(const std::shared_ptr& context, const SockAddr &peerAddr, - bool reconn); + bool reconn +#ifdef PVXS_ENABLE_OPENSSL + , bool isTLS +#endif + ); virtual ~Connection(); static std::shared_ptr build(const std::shared_ptr& context, const SockAddr& serv, - bool reconn=false); + bool reconn +#ifdef PVXS_ENABLE_OPENSSL + , bool isTLS +#endif + ); private: void startConnecting(); @@ -157,7 +181,7 @@ struct ConnectImpl final : public Connect std::shared_ptr chan; const std::string _name; std::atomic _connected; - std::function _onConn; + std::function _onConn; std::function _onDis; ConnectImpl(const evbase& loop, const std::string& name) @@ -191,7 +215,7 @@ struct Channel { uint32_t sid = 0u; // channel created with .server() to bypass normal search process - SockAddr forcedServer; + SockEndpoint forcedServer; // when state==Searching, number of repetitions size_t nSearch = 0u; @@ -254,7 +278,7 @@ struct ContextImpl : public std::enable_shared_from_this Stopped, } state = Init; - const Config effective; + Config effective; const Value caMethod; @@ -304,9 +328,14 @@ struct ContextImpl : public std::enable_shared_from_this // chanByName key'd by (pv, forceServer) std::map, std::shared_ptr> chanByName; +#ifdef PVXS_ENABLE_OPENSSL + // pair (addr, useTLS) + std::map, std::weak_ptr> connByAddr; +#else std::map> connByAddr; +#endif - std::vector>> nameServers; + std::vector>> nameServers; evbase tcp_loop; const evevent searchRx4, searchRx6; @@ -323,6 +352,26 @@ struct ContextImpl : public std::enable_shared_from_this const evevent cacheCleaner; const evevent nsChecker; +#ifdef PVXS_ENABLE_OPENSSL + inline bool connectionCanProceed() const { + return + !tls_context // If this is not a TLS context then we can proceed immediately without waiting for status + || !effective.isTlsConfigured() // TLS is not configured + || !tls_context.has_cert // If no certificate has been loaded then we can't establish a TLS context, so proceed with tcp + || (tls_context.cert_is_valid) // If we have a cert and have already received status from the CMS, then proceed now + || tls_context.status_check_disabled // or we don't have to wait for status, then proceed now + || !cert_status_manager // If we have no active subscription then we'll never get status so go ahead now with tcp + || (cert_status_manager->available(effective.request_timeout_specified));// Finally if the subscription has an available status, or we've waited long enough, then use it + } + ossl::SSLContext tls_context; + evevent cert_event_timer; + evevent cert_validity_timer; + bool first_cert_event{true}; + std::shared_ptr current_status; + certs::P12FileWatcher file_watcher; + certs::cert_status_ptr cert_status_manager; +#endif + INST_COUNTER(ClientContextImpl); ContextImpl(const Config& conf, const evbase &tcp_loop); @@ -352,6 +401,21 @@ struct ContextImpl : public std::enable_shared_from_this static void cacheCleanS(evutil_socket_t fd, short evt, void *raw); void onNSCheck(); static void onNSCheckS(evutil_socket_t fd, short evt, void *raw); + + private: + friend class client::Context; + +#ifdef PVXS_ENABLE_OPENSSL + static void doCertEventHandler(evutil_socket_t fd, short evt, void *raw); + static void doCertStatusValidityEventhandler(evutil_socket_t fd, short evt, void *raw); + void fileEventCallback(short evt); + X509 * getCert(ossl::SSLContext *context = nullptr); + void startStatusValidityTimer(); + void subscribeToCertStatus(); + public: + void disableTls(); + void enableTls(const Config& new_config = {}); +#endif }; struct Context::Pvt { @@ -364,7 +428,11 @@ struct Context::Pvt { INST_COUNTER(ClientPvt); +#ifndef PVXS_ENABLE_OPENSSL + Pvt(const Config& conf); +#else Pvt(const Config& conf); +#endif ~Pvt(); // I call ContextImpl::close() }; diff --git a/src/clientintrospect.cpp b/src/clientintrospect.cpp index 590073cab..f12252bb4 100644 --- a/src/clientintrospect.cpp +++ b/src/clientintrospect.cpp @@ -12,8 +12,8 @@ namespace pvxs { namespace client { -DEFINE_LOGGER(setup, "pvxs.client.setup"); -DEFINE_LOGGER(io, "pvxs.client.io"); +DEFINE_LOGGER(setup, "pvxs.cli.init"); +DEFINE_LOGGER(io, "pvxs.cli.io"); namespace { @@ -215,9 +215,8 @@ std::shared_ptr GetBuilder::_exec_info() auto name(std::move(_name)); auto server(std::move(_server)); - context->tcp_loop.dispatch([op, context, name, server]() { + context->tcp_loop.dispatch([=]() { // on worker - try { op->chan = Channel::build(context, name, server); diff --git a/src/clientmon.cpp b/src/clientmon.cpp index 0c1f9b02f..f6b87b3e9 100644 --- a/src/clientmon.cpp +++ b/src/clientmon.cpp @@ -18,8 +18,8 @@ namespace client { typedef epicsGuard Guard; -DEFINE_LOGGER(monevt, "pvxs.client.monitor"); -DEFINE_LOGGER(io, "pvxs.client.io"); +DEFINE_LOGGER(monevt, "pvxs.cli.mon"); +DEFINE_LOGGER(io, "pvxs.cli.io"); namespace { struct Entry { @@ -359,7 +359,9 @@ struct SubscriptionImpl final : public OperationBase, public Subscription if(!maskConn) { notify = queue.empty() && wantToNotify(); - queue.emplace_back(std::make_exception_ptr(Connected(conn->peerName))); + queue.emplace_back(std::make_exception_ptr(Connected(conn->peerName, + conn->connTime, + conn->cred))); log_debug_printf(io, "Server %s channel %s monitor PUSH Connected\n", chan->conn ? chan->conn->peerName.c_str() : "", @@ -814,9 +816,8 @@ std::shared_ptr MonitorBuilder::exec() }); auto server(std::move(_server)); - context->tcp_loop.dispatch([op, context, server]() { + context->tcp_loop.dispatch([=]() { // on worker - try { op->chan = Channel::build(context, op->channelName, server); diff --git a/src/config.cpp b/src/config.cpp index 96bca3ebc..6da1ddc67 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -5,32 +5,69 @@ */ #include -#include -#include -#include -#include #include +#include +#include + +#ifdef __unix__ +#include +#endif +#include +#include +#include +#include -#include -#include -#include #include #include +#include +#include #include -#include "serverconn.h" + +#include + #include "clientimpl.h" -#include "utilpvt.h" #include "evhelper.h" +#include "serverconn.h" +#include "utilpvt.h" -DEFINE_LOGGER(serversetup, "pvxs.server.setup"); -DEFINE_LOGGER(clientsetup, "pvxs.client.setup"); +DEFINE_LOGGER(serversetup, "pvxs.svr.init"); +DEFINE_LOGGER(clientsetup, "pvxs.cli.init"); DEFINE_LOGGER(config, "pvxs.config"); namespace pvxs { -SockEndpoint::SockEndpoint(const char* ep, uint16_t defport) -{ +namespace impl { +ConfigCommon::~ConfigCommon() {} + +} // namespace impl + +SockEndpoint::SockEndpoint(const char* ep, const impl::ConfigCommon* conf, uint16_t defdefport) { + uint16_t defport = conf ? conf->tcp_port : defdefport; + // look for URI-ish prefix + std::string urlstore; + if (auto sep = strstr(ep, "://")) { + auto schemeLen = sep - ep; + if (!conf) { + throw std::runtime_error("URI unsupported in this context"); +#ifdef PVXS_ENABLE_OPENSSL + } else if (schemeLen == 4u && strncmp(ep, "pvas", 4) == 0) { + scheme = TLS; + defport = conf ? conf->tls_port : defdefport; +#endif + } else if (schemeLen == 3u && strncmp(ep, "pva", 3) == 0) { + scheme = Plain; + } else { + throw std::runtime_error(SB() << "Unsupported scheme '" << ep << "'"); + } + ep = sep + 3u; // skip past prefix + if (auto end = strchr(ep, '/')) { // trailing '/' + // copy only host_ip:port + urlstore.assign(ep, end - ep); + ep = urlstore.c_str(); + } + } + // // , // @ifacename @@ -38,80 +75,77 @@ SockEndpoint::SockEndpoint(const char* ep, uint16_t defport) auto comma = strchr(ep, ','); auto at = strchr(ep, '@'); - if(comma && at && comma > at) { - throw std::runtime_error(SB()<<'"'< at) { + throw std::runtime_error(SB() << '"' << escape(ep) << "\" comma expected before @"); } - if(!comma && !at) { + if (!comma && !at) { addr.setAddress(ep, defport); - } else { // comma || at + } else { // comma || at auto firstsep = comma ? comma : at; - addr.setAddress(std::string(ep, firstsep-ep), defport); + addr.setAddress(std::string(ep, firstsep - ep), defport); - if(comma && !at) { - ttl = parseTo(comma+1); + if (comma && !at) { + ttl = parseTo(comma + 1); - } else if(comma) { - ttl = parseTo(std::string(comma+1, at-comma-1)); + } else if (comma) { + ttl = parseTo(std::string(comma + 1, at - comma - 1)); } - if(at) - iface = at+1; + if (at) iface = at + 1; } auto& ifmap = IfaceMap::instance(); - if(addr.family()==AF_INET6) { - if(iface.empty() && addr->in6.sin6_scope_id) { + if (addr.family() == AF_INET6) { + if (iface.empty() && addr->in6.sin6_scope_id) { // interface index provide with IPv6 address // we map back to symbolic name for storage iface = ifmap.name_of(addr->in6.sin6_scope_id); } addr->in6.sin6_scope_id = 0; - } else if(addr.family()==AF_INET && addr.isMCast() && !iface.empty()) { + } else if (addr.family() == AF_INET && addr.isMCast() && !iface.empty()) { SockAddr ifaddr(AF_INET); - if(evutil_inet_pton(AF_INET, iface.c_str(), &ifaddr->in.sin_addr.s_addr)==1) { + if (evutil_inet_pton(AF_INET, iface.c_str(), &ifaddr->in.sin_addr.s_addr) == 1) { // map interface address to symbolic name iface = ifmap.name_of(ifaddr); } } - if(!iface.empty() && !ifmap.index_of(iface)) { + if (!iface.empty() && !ifmap.index_of(iface)) { log_warn_printf(config, "Invalid interface address or name: \"%s\"\n", iface.c_str()); } } -MCastMembership SockEndpoint::resolve() const -{ - if(!addr.isMCast()) - throw std::logic_error("not mcast"); +MCastMembership SockEndpoint::resolve() const { + if (!addr.isMCast()) throw std::logic_error("not mcast"); auto& ifmap = IfaceMap::instance(); MCastMembership m; m.af = addr.family(); - if(m.af==AF_INET) { + if (m.af == AF_INET) { auto& req = m.req.in; req.imr_multiaddr.s_addr = addr->in.sin_addr.s_addr; - if(!iface.empty()) { + if (!iface.empty()) { auto iface = ifmap.address_of(this->iface); - if(iface.family()==AF_INET) { + if (iface.family() == AF_INET) { req.imr_interface.s_addr = iface->in.sin_addr.s_addr; } } - } else if(m.af==AF_INET6) { + } else if (m.af == AF_INET6) { auto& req = m.req.in6; req.ipv6mr_multiaddr = addr->in6.sin6_addr; - if(!iface.empty()) { + if (!iface.empty()) { req.ipv6mr_interface = ifmap.index_of(this->iface); - if(!req.ipv6mr_interface) { + if (!req.ipv6mr_interface) { log_warn_printf(config, "Unable to resolve interface '%s'\n", iface.c_str()); } } @@ -122,22 +156,17 @@ MCastMembership SockEndpoint::resolve() const return m; } -std::ostream& operator<<(std::ostream& strm, const SockEndpoint& addr) -{ - strm<& out, const std::string& inp, - uint16_t defaultPort, bool required=false) -{ - size_t pos=0u; +// remove duplicates while preserving order of first appearance +template +void removeDups(std::vector& addrs) { + std::sort(addrs.begin(), addrs.end()); + addrs.erase(std::unique(addrs.begin(), addrs.end()), addrs.end()); +} - // parse, resolve host names, then re-print. - // Catch syntax errors early, and normalize prior to removing duplicates - while(pos +void removeDups(std::vector& addrs) { + std::map, size_t> seen; + for (size_t i = 0; i < addrs.size();) { + auto& ep = addrs[i]; + auto key = std::make_pair(ep.addr, ep.iface); + auto it = seen.find(key); + if (it == seen.end()) { // first sighting + seen[key] = i++; + + } else { // duplicate + auto& orig = addrs[it->second]; + + if (ep.ttl > orig.ttl) { // w/ longer TTL + orig.ttl = ep.ttl; + } + + addrs.erase(addrs.begin() + i); + // 'ep' and 'orig' are invalidated + } + } +} + +void split_into(std::vector& out, const std::string& inp) { + size_t pos = 0u; + + while (pos < inp.size()) { auto start = inp.find_first_not_of(" \t\r\n", pos); auto end = inp.find_first_of(" \t\r\n", start); pos = end; - if(start& in) -{ +void split_addr_into(const char* name, std::vector& out, const std::string& inp, const impl::ConfigCommon* conf, uint16_t defaultPort, + bool required = false) { + std::vector raw; + split_into(raw, inp); + + // parse, resolve host names, then re-print. + // Catch syntax errors early, and normalize prior to removing duplicates + for (auto& temp : raw) { + try { + SockEndpoint ep(temp, conf, defaultPort); + out.push_back(SB() << ep); + + } catch (std::exception& e) { + if (required) throw std::runtime_error(SB() << "invalid endpoint \"" << temp << "\" " << e.what()); + log_err_printf(config, "%s ignoring invalid '%s' : %s\n", name, temp.c_str(), e.what()); + } + } +} + +std::string join_addr(const std::vector& in) { std::ostringstream strm; - bool first=true; - for(auto& addr : in) { - if(first) + bool first = true; + for (auto& addr : in) { + if (first) first = false; else - strm<<' '; - strm<(val); - if(!std::isfinite(temp) - || temp<0.0 - || temp>double(std::numeric_limits::max())) - throw std::out_of_range("Out of range"); + if (!std::isfinite(temp) || temp < 0.0 || temp > double(std::numeric_limits::max())) throw std::out_of_range("Out of range"); - dest = temp*tmoScale; - } catch(std::exception& e) { - log_err_printf(serversetup, "%s invalid double value : '%s'\n", - name.c_str(), val.c_str()); + dest = temp * tmoScale; + } catch (std::exception& e) { + log_err_printf(serversetup, "%s invalid double value : '%s'\n", name.c_str(), val.c_str()); } } -struct PickOne { - const std::map& defs; - bool useenv; - - std::string name, val; - - bool operator()(std::initializer_list names) { - for(auto candidate : names) { - if(useenv) { - if(auto eval = getenv(candidate)) { - name = candidate; - val = eval; - return true; - } - - } else { - auto it = defs.find(candidate); - if(it!=defs.end()) { - name = candidate; - val = it->second; - return true; - } - } - } - return false; - } -}; - -std::vector parseAddresses(const std::vector& addrs, uint16_t defport=0) -{ +std::vector parseAddresses(const std::vector& addrs) { std::vector ret; - for(const auto& addr : addrs) { + for (const auto& addr : addrs) { try { - ret.emplace_back(addr, defport); - }catch(std::runtime_error& e){ + ret.emplace_back(addr); + } catch (std::runtime_error& e) { log_warn_printf(config, "Ignoring %s : %s\n", addr.c_str(), e.what()); continue; } @@ -268,64 +293,57 @@ std::vector parseAddresses(const std::vector& addrs, return ret; } -void printAddresses(std::vector& out, const std::vector& inp) -{ +void printAddresses(std::vector& out, const std::vector& inp) { std::vector temp; temp.reserve(inp.size()); - for(auto& addr : inp) { - temp.emplace_back(SB()<& ifaces, - std::vector& addrs) -{ +void expandAddrList(const std::vector& ifaces, std::vector& addrs) { SockAttach attach; evsocket dummy(AF_INET, SOCK_DGRAM, 0); - for(auto& saddr : ifaces) { + for (auto& saddr : ifaces) { auto matchAddr = &saddr.addr; - if(evsocket::ipstack==evsocket::Linsock && saddr.addr.family()==AF_INET6 && saddr.addr.isAny()) { + if (evsocket::ipstack == evsocket::Linsock && saddr.addr.family() == AF_INET6 && saddr.addr.isAny()) { // special case handling to match "promote" in server::Config::expand() // treat [::] as 0.0.0.0 matchAddr = nullptr; - } else if(saddr.addr.family()!=AF_INET) { + } else if (saddr.addr.family() != AF_INET) { continue; } - for(auto& addr : dummy.broadcasts(matchAddr)) { + for (auto& addr : dummy.broadcasts(matchAddr)) { addr.setPort(0u); addrs.emplace_back(addr); } } } -void addGroups(std::vector& ifaces, - const std::vector& addrs) -{ +void addGroups(std::vector& ifaces, const std::vector& addrs) { auto& ifmap = IfaceMap::instance(); std::set allifaces; - for(const auto& addr : addrs) { - if(!addr.addr.isMCast()) - continue; + for (const auto& addr : addrs) { + if (!addr.addr.isMCast()) continue; - if(!addr.iface.empty()) { + if (!addr.iface.empty()) { // interface already specified ifaces.push_back(addr); } else { // no interface specified, treat as wildcard - if(allifaces.empty()) - allifaces = ifmap.all_external(); + if (allifaces.empty()) allifaces = ifmap.all_external(); - for(auto& iface : allifaces) { + for (auto& iface : allifaces) { auto ifaceaddr(addr); ifaceaddr.iface = iface; ifaces.push_back(ifaceaddr); @@ -334,43 +352,7 @@ void addGroups(std::vector& ifaces, } } -// remove duplicates while preserving order of first appearance -template -void removeDups(std::vector& addrs) -{ - std::sort(addrs.begin(), addrs.end()); - addrs.erase(std::unique(addrs.begin(), addrs.end()), - addrs.end()); -} - -// special handling for SockEndpoint where duplication is based on -// address,interface. Duplicates are combined with the longest TTL. -template<> -void removeDups(std::vector& addrs) -{ - std::map, size_t> seen; - for(size_t i=0; isecond]; - - if(ep.ttl > orig.ttl) { // w/ longer TTL - orig.ttl = ep.ttl; - } - - addrs.erase(addrs.begin()+i); - // 'ep' and 'orig' are invalidated - } - } -} - -void enforceTimeout(double& tmo) -{ +void enforceTimeout(double& tmo) { /* Inactivity timeouts with PVA have a long (and growing) history. * * - Originally pvAccessCPP clients didn't send CMD_ECHO, and servers would never timeout. @@ -383,124 +365,309 @@ void enforceTimeout(double& tmo) * - As a compromise, continue to send echo at least every 15 seconds, * and increase default timeout to 40. */ - if(!std::isfinite(tmo) || tmo <= 0.0 || tmo >= double(std::numeric_limits::max())) + if (!std::isfinite(tmo) || tmo <= 0.0 || tmo >= double(std::numeric_limits::max())) tmo = 40.0; - else if(tmo < 2.0) + else if (tmo < 2.0) tmo = 2.0; } -} // namespace +#ifdef PVXS_ENABLE_OPENSSL +void parseTLSOptions(ConfigCommon& conf, const std::string& options) { + std::vector opts; + split_into(opts, options); + + for (auto opt : opts) { + auto sep(opt.find_first_of('=')); + if ( sep == std::string::npos) + sep = opt.size(); + auto key(opt.substr(0, sep)); + auto val(sep <= key.size() ? opt.substr(sep + 1) : std::string()); + + if (key == "client_cert") { + if (val == "require") { + conf.tls_client_cert_required = ConfigCommon::Require; + } else if (val == "optional") { + conf.tls_client_cert_required = ConfigCommon::Optional; + } else { + log_warn_printf(config, "Ignore unknown TLS option `client_cert` value %s. expected `require` or `optional`\n", opt.c_str()); + } + } else if (key == "on_expiration") { + if (val == "fallback-to-tcp") { + conf.expiration_behaviour = ConfigCommon::FallbackToTCP; + } else if (val == "shutdown") { + conf.expiration_behaviour = ConfigCommon::Shutdown; + } else if (val == "standby") { + conf.expiration_behaviour = ConfigCommon::Standby; + } else { + log_warn_printf(config, "Ignore unknown TLS option `on_expiration` value %s. expected `fallback-to-tcp`, `shutdown` or `standby`\n", opt.c_str()); + } + } else if (key == "on_no_cms") { + if (val == "fallback-to-tcp") { + conf.tls_throw_if_cant_verify = false; + } else if (val == "throw") { + conf.tls_throw_if_cant_verify = true; + } else { + log_warn_printf(config, "Ignore unknown TLS option `on_no_cms` value %s. expected `fallback-to-tcp` or `throw`\n", opt.c_str()); + } + } else if (key == "no_revocation_check") { + if ( val.empty()) + conf.tls_disable_status_check = true; + else + log_warn_printf(config, "Ignore unknown TLS option `no_revocation_check` value %s. no value expected\n", opt.c_str()); + } else if (key == "no_stapling") { + if ( val.empty()) + conf.tls_disable_stapling = true; + else + log_warn_printf(config, "Ignore unknown TLS option `no_stapling` value %s. no value expected\n", opt.c_str()); + } else { + log_warn_printf(config, "Ignore unknown TLS option key %s\n", opt.c_str()); + } + } +} + +std::string printTLSOptions(const ConfigCommon& conf) { + std::vector opts; + switch (conf.tls_client_cert_required) { + case ConfigCommon::Default: + break; + case ConfigCommon::Optional: + opts.push_back("client_cert=optional"); + break; + case ConfigCommon::Require: + opts.push_back("client_cert=require"); + break; + } + switch (conf.expiration_behaviour) { + case ConfigCommon::FallbackToTCP: + opts.push_back("on_expiration=fallback-to-tcp"); + break; + case ConfigCommon::Shutdown: + opts.push_back("on_expiration=shutdown"); + break; + case ConfigCommon::Standby: + opts.push_back("on_expiration=standby"); + break; + } + if ( conf.tls_disable_status_check) + opts.push_back("no_revocation_check"); + if ( conf.tls_disable_stapling) + opts.push_back("no_stapling"); + if ( conf.tls_throw_if_cant_verify) + opts.push_back("on_no_cms=throw"); + else + opts.push_back("on_no_cms=fallback-to-tcp"); + return join_addr(opts); +} +#endif + +} // namespace namespace server { -static -void _fromDefs(Config& self, const std::map& defs, bool useenv) -{ +void Config::fromDefs(Config& self, const std::map& defs, bool useenv) { PickOne pickone{defs, useenv}; + PickOne pick_another_one{defs, useenv}; - if(pickone({"EPICS_PVAS_SERVER_PORT", "EPICS_PVA_SERVER_PORT"})) { + if (pickone({"EPICS_PVAS_SERVER_PORT", "EPICS_PVA_SERVER_PORT"})) { try { self.tcp_port = parseTo(pickone.val); - }catch(std::exception& e) { + } catch (std::exception& e) { log_err_printf(serversetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); } } - if(pickone({"EPICS_PVAS_BROADCAST_PORT", "EPICS_PVA_BROADCAST_PORT"})) { + if (pickone({"EPICS_PVAS_BROADCAST_PORT", "EPICS_PVA_BROADCAST_PORT"})) { try { self.udp_port = parseTo(pickone.val); - }catch(std::exception& e) { + } catch (std::exception& e) { log_err_printf(serversetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); } } - if(pickone({"EPICS_PVAS_INTF_ADDR_LIST"})) { - split_addr_into(pickone.name.c_str(), self.interfaces, pickone.val, self.tcp_port, true); + if (pickone({"EPICS_PVAS_INTF_ADDR_LIST"})) { + split_addr_into(pickone.name.c_str(), self.interfaces, pickone.val, nullptr, self.tcp_port, true); } - if(pickone({"EPICS_PVAS_IGNORE_ADDR_LIST"})) { - split_addr_into(pickone.name.c_str(), self.ignoreAddrs, pickone.val, 0, true); + if (pickone({"EPICS_PVAS_IGNORE_ADDR_LIST"})) { + split_addr_into(pickone.name.c_str(), self.ignoreAddrs, pickone.val, nullptr, 0, true); } - if(pickone({"EPICS_PVAS_BEACON_ADDR_LIST", "EPICS_PVA_ADDR_LIST"})) { - split_addr_into(pickone.name.c_str(), self.beaconDestinations, pickone.val, self.udp_port); + if (pickone({"EPICS_PVAS_BEACON_ADDR_LIST", "EPICS_PVA_ADDR_LIST"})) { + split_addr_into(pickone.name.c_str(), self.beaconDestinations, pickone.val, nullptr, self.udp_port); } - if(pickone({"EPICS_PVAS_AUTO_BEACON_ADDR_LIST", "EPICS_PVA_AUTO_ADDR_LIST"})) { + if (pickone({"EPICS_PVAS_AUTO_BEACON_ADDR_LIST", "EPICS_PVA_AUTO_ADDR_LIST"})) { parse_bool(self.auto_beacon, pickone.name, pickone.val); } - if(pickone({"EPICS_PVA_CONN_TMO"})) { + if (pickone({"EPICS_PVA_CONN_TMO"})) { parse_timeout(self.tcpTimeout, pickone.name, pickone.val); } -} -Config& Config::applyEnv() -{ - _fromDefs(*this, std::map(), true); +#ifdef PVXS_ENABLE_OPENSSL + // EPICS_PVAS_TLS_KEYCHAIN + if (pickone({"EPICS_PVAS_TLS_KEYCHAIN", "EPICS_PVA_TLS_KEYCHAIN"})) { + self.ensureDirectoryExists(self.tls_cert_filename = self.tls_private_key_filename = pickone.val); + // EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE + std::string password_filename; + if (pickone.name == "EPICS_PVAS_TLS_KEYCHAIN") { + pick_another_one({"EPICS_PVAS_TLS_KEYCHAIN_PWD_FILE"}); + password_filename = pick_another_one.val; + } else { + pick_another_one({"EPICS_PVA_TLS_KEYCHAIN_PWD_FILE"}); + password_filename = pick_another_one.val; + } + self.ensureDirectoryExists(password_filename); + try { + self.tls_cert_password = self.tls_private_key_password = self.getFileContents(password_filename); + } catch (std::exception& e) { + log_err_printf(serversetup, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + + // EPICS_PVAS_TLS_PKEY + if (pickone({"EPICS_PVAS_TLS_PKEY", "EPICS_PVA_TLS_PKEY"})) { + self.ensureDirectoryExists(self.tls_private_key_filename = pickone.val); + // EPICS_PVAS_TLS_PKEY_PWD_FILE + std::string password_filename; + if (pickone.name == "EPICS_PVAS_TLS_PKEY") { + pick_another_one({"EPICS_PVAS_TLS_PKEY_PWD_FILE"}); + password_filename = pick_another_one.val; + } else { + pick_another_one({"EPICS_PVA_TLS_PKEY_PWD_FILE"}); + password_filename = pick_another_one.val; + } + self.ensureDirectoryExists(password_filename); + try { + self.tls_private_key_password = self.getFileContents(password_filename); + } catch (std::exception& e) { + log_err_printf(serversetup, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + + // EPICS_PVAS_TLS_OPTIONS + if (pickone({"EPICS_PVAS_TLS_OPTIONS", "EPICS_PVA_TLS_OPTIONS"})) { + parseTLSOptions(self, pickone.val); + } + + // EPICS_PVAS_TLS_PORT + if (pickone({"EPICS_PVAS_TLS_PORT", "EPICS_PVA_TLS_PORT"})) { + try { + self.tls_port = parseTo(pickone.val); + } catch (std::exception& e) { + log_err_printf(serversetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); + } + } + + // EPICS_PVAS_TLS_STOP_IF_NO_CERT + if (pickone({"EPICS_PVAS_TLS_STOP_IF_NO_CERT"})) { + self.tls_stop_if_no_cert = parseTo(pickone.val); + } +#endif // PVXS_ENABLE_OPENSSL + is_initialized = true; +} +#ifndef PVXS_ENABLE_OPENSSL + +Config& Config::applyEnv() { +#else +Config& Config::applyEnv(const bool tls_disabled, const ConfigTarget target) { + this->tls_disabled = tls_disabled; + this->config_target = target; +#endif + fromDefs(*this, std::map(), true); return *this; } -Config Config::isolated(int family) -{ +/** + * @brief Create a Config object with default values suitable for isolated testing + * + * @param family AF_INET or AF_INET6 + * @return Config + */ +Config Config::isolated(int family) { Config ret; ret.udp_port = 0u; ret.tcp_port = 0u; ret.auto_beacon = false; - switch(family) { - case AF_INET: - ret.interfaces.emplace_back("127.0.0.1"); - ret.beaconDestinations.emplace_back("127.0.0.1"); - break; - case AF_INET6: - ret.interfaces.emplace_back("::1"); - ret.beaconDestinations.emplace_back("::1"); - break; - default: - throw std::logic_error(SB()<<"Unsupported address family "<& defs) -{ - _fromDefs(*this, defs, false); +Config& Config::applyDefs(const std::map& defs) { + fromDefs(*this, defs, false); return *this; } -void Config::updateDefs(defs_t& defs) const -{ - defs["EPICS_PVA_BROADCAST_PORT"] = defs["EPICS_PVAS_BROADCAST_PORT"] = SB()< adds all bcasts // otherwise add bcast for each iface address @@ -524,117 +691,193 @@ void Config::expand() removeDups(ignoreAddrs); enforceTimeout(tcpTimeout); - } -std::ostream& operator<<(std::ostream& strm, const Config& conf) -{ +std::ostream& operator<<(std::ostream& strm, const Config& conf) { Config::defs_t defs; conf.updateDefs(defs); - for(const auto& pair : defs) { + for (const auto& pair : defs) { // only print the server variant static const char prefix[] = "EPICS_PVAS_"; - if(pair.first.size() >= sizeof(prefix)-1u && strncmp(pair.first.c_str(), - prefix, - sizeof(prefix)-1u)==0) - strm<= sizeof(prefix) - 1u && strncmp(pair.first.c_str(), prefix, sizeof(prefix) - 1u) == 0) || + (pair.first.size() >= sizeof(ca_prefix) - 1u && strncmp(pair.first.c_str(), ca_prefix, sizeof(ca_prefix) - 1u) == 0) || + (pair.first.size() >= sizeof(pvacms_prefix) - 1u && strncmp(pair.first.c_str(), pvacms_prefix, sizeof(pvacms_prefix) - 1u) == 0) || + (pair.first.size() >= sizeof(ocsp_prefix) - 1u && strncmp(pair.first.c_str(), ocsp_prefix, sizeof(ocsp_prefix) - 1u) == 0) || + (pair.first.size() >= sizeof(auth_prefix) - 1u && strncmp(pair.first.c_str(), auth_prefix, sizeof(auth_prefix) - 1u) == 0)) + strm << indent{} << pair.first << '=' << pair.second << '\n'; } return strm; } -} // namespace server +} // namespace server namespace client { -static -void _fromDefs(Config& self, const std::map& defs, bool useenv) -{ +void Config::fromDefs(Config& self, const std::map& defs, bool useenv) { PickOne pickone{defs, useenv}; - if(pickone({"EPICS_PVA_BROADCAST_PORT"})) { + if (pickone({"EPICS_PVA_BROADCAST_PORT"})) { try { self.udp_port = parseTo(pickone.val); - }catch(std::exception& e) { + } catch (std::exception& e) { log_warn_printf(clientsetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); } } - if(self.udp_port==0u) { + if (self.udp_port == 0u) { log_warn_printf(clientsetup, "ignoring EPICS_PVA_BROADCAST_PORT=%d\n", 0); self.udp_port = 5076; } - if(pickone({"EPICS_PVA_SERVER_PORT", "EPICS_PVAS_SERVER_PORT"})) { + if (pickone({"EPICS_PVA_SERVER_PORT", "EPICS_PVAS_SERVER_PORT"})) { try { self.tcp_port = parseTo(pickone.val); - }catch(std::exception& e) { + } catch (std::exception& e) { log_warn_printf(clientsetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); } } - if(self.tcp_port==0u && !self.nameServers.empty()) { + if (self.tcp_port == 0u && !self.nameServers.empty()) { log_warn_printf(clientsetup, "ignoring EPICS_PVA_SERVER_PORT=%d\n", 0); self.tcp_port = 5075; } - if(pickone({"EPICS_PVA_ADDR_LIST"})) { - split_addr_into(pickone.name.c_str(), self.addressList, pickone.val, self.udp_port); + if (pickone({"EPICS_PVA_ADDR_LIST"})) { + split_addr_into(pickone.name.c_str(), self.addressList, pickone.val, nullptr, self.udp_port); } - if(pickone({"EPICS_PVA_NAME_SERVERS"})) { - split_addr_into(pickone.name.c_str(), self.nameServers, pickone.val, self.tcp_port); + if (pickone({"EPICS_PVA_NAME_SERVERS"})) { + split_addr_into(pickone.name.c_str(), self.nameServers, pickone.val, &self, 0); } - if(pickone({"EPICS_PVA_AUTO_ADDR_LIST"})) { + if (pickone({"EPICS_PVA_AUTO_ADDR_LIST"})) { parse_bool(self.autoAddrList, pickone.name, pickone.val); } - if(pickone({"EPICS_PVA_INTF_ADDR_LIST"})) { - split_addr_into(pickone.name.c_str(), self.interfaces, pickone.val, 0); + if (pickone({"EPICS_PVA_INTF_ADDR_LIST"})) { + split_addr_into(pickone.name.c_str(), self.interfaces, pickone.val, nullptr, 0); } - if(pickone({"EPICS_PVA_CONN_TMO"})) { + if (pickone({"EPICS_PVA_CONN_TMO"})) { parse_timeout(self.tcpTimeout, pickone.name, pickone.val); } + +#ifdef PVXS_ENABLE_OPENSSL + // EPICS_PVA_TLS_KEYCHAIN + if (pickone({"EPICS_PVA_TLS_KEYCHAIN"})) { + self.ensureDirectoryExists(self.tls_cert_filename = self.tls_private_key_filename = pickone.val); + } + + // EPICS_PVA_TLS_KEYCHAIN_PWD_FILE + if (pickone({"EPICS_PVA_TLS_KEYCHAIN_PWD_FILE"})) { + std::string password_filename(pickone.val); + try { + self.ensureDirectoryExists(password_filename); + self.tls_cert_password = self.tls_private_key_password = self.getFileContents(password_filename); + } catch (std::exception& e) { + log_err_printf(serversetup, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + + // EPICS_PVA_TLS_PKEY + if (pickone({"EPICS_PVA_TLS_PKEY"})) { + self.ensureDirectoryExists(self.tls_private_key_filename = pickone.val); + } + + // EPICS_PVA_TLS_PKEY_PWD_FILE + if (pickone({"EPICS_PVA_TLS_PKEY_PWD_FILE"})) { + std::string password_filename(pickone.val); + try { + self.ensureDirectoryExists(password_filename); + self.tls_private_key_password = self.getFileContents(password_filename); + } catch (std::exception& e) { + log_err_printf(serversetup, "error reading password file: %s. %s", password_filename.c_str(), e.what()); + } + } + + // EPICS_PVA_TLS_OPTIONS + if (pickone({"EPICS_PVA_TLS_OPTIONS"})) { + parseTLSOptions(self, pickone.val); + } + + // EPICS_PVA_TLS_PORT + if (pickone({"EPICS_PVA_TLS_PORT"})) { + try { + self.tls_port = parseTo(pickone.val); + } catch (std::exception& e) { + log_err_printf(serversetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); + } + } +#endif // PVXS_ENABLE_OPENSSL + is_initialized = true; } -Config& Config::applyEnv() -{ - _fromDefs(*this, std::map(), true); +#ifndef PVXS_ENABLE_OPENSSL +Config& Config::applyEnv() { +#else +Config& Config::applyEnv(const bool tls_disabled, const ConfigTarget target) { + this->tls_disabled = tls_disabled; + this->config_target = target; +#endif + fromDefs(*this, std::map(), true); return *this; } -Config& Config::applyDefs(const std::map& defs) -{ - _fromDefs(*this, defs, false); +#ifdef PVXS_ENABLE_OPENSSL +Config& Config::applyEnv(const bool tls_disabled) { return applyEnv(tls_disabled, CLIENT); } + +#endif // PVXS_ENABLE_OPENSSL + +Config& Config::applyDefs(const std::map& defs) { + fromDefs(*this, defs, false); return *this; } -void Config::updateDefs(defs_t& defs) const -{ - defs["EPICS_PVA_BROADCAST_PORT"] = SB()<bev) temp(__FILE__, __LINE__, bev); - connect(std::move(temp)); + connect(std::move(bev)); } } diff --git a/src/conn.h b/src/conn.h index 53491b96c..311777a08 100644 --- a/src/conn.h +++ b/src/conn.h @@ -50,7 +50,7 @@ struct ConnBase Disconnected, } state; - ConnBase(bool isClient, bool sendBE, bufferevent* bev, const SockAddr& peerAddr); + ConnBase(bool isClient, bool sendBE, evbufferevent &&bev, const SockAddr& peerAddr); ConnBase(const ConnBase&) = delete; ConnBase& operator=(const ConnBase&) = delete; virtual ~ConnBase(); @@ -61,7 +61,7 @@ struct ConnBase bufferevent* connection() { return bev.get(); } - void connect(ev_owned_ptr&& bev); + void connect(ev_owned_ptr &&bev); void disconnect(); protected: diff --git a/src/describe.cpp b/src/describe.cpp index 66bd96cea..980a837bc 100644 --- a/src/describe.cpp +++ b/src/describe.cpp @@ -137,6 +137,11 @@ std::ostream& version_information(std::ostream& strm) strm<lock); + if(!pvt->running) { + if(dothrow) + throw std::logic_error("Worker stopped"); + return false; + } + empty = pvt->actions.empty(); + pvt->actions.emplace_back(std::move(fn), nullptr, nullptr); + } + + if(empty && event_add(pvt->dowork.get(), &delay)) + throw std::runtime_error("Unable to wakeup dispatch()"); + + return true; +} + bool evbase::_call(mfunction&& fn, bool dothrow) const { if(pvt->worker.isCurrentThread()) { diff --git a/src/evhelper.h b/src/evhelper.h index ce45cd6b3..63ceb86f0 100644 --- a/src/evhelper.h +++ b/src/evhelper.h @@ -19,40 +19,22 @@ #include #include +#ifdef PVXS_ENABLE_OPENSSL +# include +#endif + #include #include #include #include "pvaproto.h" +#include "ownedptr.h" -// hooks for std::unique_ptr -namespace std { -template<> -struct default_delete { - inline void operator()(event_config* ev) { event_config_free(ev); } -}; -template<> -struct default_delete { - inline void operator()(event_base* ev) { event_base_free(ev); } -}; -template<> -struct default_delete { - inline void operator()(event* ev) { event_free(ev); } -}; -template<> -struct default_delete { - inline void operator()(evconnlistener* ev) { evconnlistener_free(ev); } -}; -template<> -struct default_delete { - inline void operator()(bufferevent* ev) { bufferevent_free(ev); } -}; -template<> -struct default_delete { - inline void operator()(evbuffer* ev) { evbuffer_free(ev); } -}; -} +#ifdef PVXS_ENABLE_OPENSSL +#include "openssl.h" +constexpr timeval status_ready_polling_interval{0, 100000}; +#endif namespace pvxs {namespace impl { @@ -70,35 +52,6 @@ DEFINE_DELETE(bufferevent); DEFINE_DELETE(evbuffer); #undef DEFINE_DELETE -//! unique_ptr which is never constructed with NULL -template -struct owned_ptr : public std::unique_ptr -{ - typedef std::unique_ptr base_t; - constexpr owned_ptr() {} - constexpr owned_ptr(std::nullptr_t np) : base_t(np) {} - explicit owned_ptr(const char* file, int line, T* ptr) : base_t(ptr) { - if(!*this) - throw loc_bad_alloc(file, line); - } - - // for functions which return a pointer in an argument - // int some(T** presult); // store *presult = output - // use like - // owned_ptr x; - // some(x.acquire()); - struct acquisition { - base_t* o; - T* ptr = nullptr; - operator T** () { return &ptr; } - constexpr acquisition(base_t* o) :o(o) {} - ~acquisition() { - o->reset(ptr); - } - }; - acquisition acquire() { return acquisition{this}; } -}; - /* It seems that std::function(Fn&&) from gcc (circa 8.3) and clang (circa 7.0) * always copies the functor/lambda. We can't allow this when transferring ownership * of shared_ptr<> instances to a worker thread as it leaves the caller thread with a @@ -155,6 +108,14 @@ struct mfunction { std::unique_ptr fn; }; +struct DelayedDispatcher { + mfunction fn; + std::function dispatch_when_condition; + DelayedDispatcher(mfunction &&fn, const std::function &&dispatch_when_condition) + : fn(std::move(fn)) + , dispatch_when_condition(dispatch_when_condition){} +}; + struct PVXS_API evbase { evbase() = default; explicit evbase(const std::string& name, unsigned prio=0); @@ -168,6 +129,7 @@ struct PVXS_API evbase { private: bool _dispatch(mfunction&& fn, bool dothrow) const; + bool _delayedDispatch(timeval delay, mfunction&& fn, bool dothrow) const; bool _call(mfunction&& fn, bool dothrow) const; public: @@ -186,6 +148,7 @@ struct PVXS_API evbase { void dispatch(mfunction&& fn) const { _dispatch(std::move(fn), true); } + inline bool tryDispatch(mfunction&& fn) const { return _dispatch(std::move(fn), false); @@ -197,23 +160,24 @@ struct PVXS_API evbase { else return tryDispatch(std::move(fn)); } - void assertInLoop() const; + //! Caller must be on the worker, or the worker must be stopped. //! @returns true if working is running. bool assertInRunningLoop() const; inline void reset() { pvt.reset(); } -private: + private: struct Pvt; std::shared_ptr pvt; -public: + + public: event_base* base = nullptr; }; template -using ev_owned_ptr = owned_ptr>; +using ev_owned_ptr = pvxs::OwnedPtr>; typedef ev_owned_ptr evconfig; typedef ev_owned_ptr evbaseptr; typedef ev_owned_ptr evevent; diff --git a/src/openssl.cpp b/src/openssl.cpp new file mode 100644 index 000000000..db2d35c9f --- /dev/null +++ b/src/openssl.cpp @@ -0,0 +1,622 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include "openssl.h" + +#include +#include +#include + +#include + +#include +#include +#include + +#include + +#include "certstatus.h" +#include "certstatusmanager.h" +#include "evhelper.h" +#include "ownedptr.h" +#include "serverconn.h" +#include "utilpvt.h" +#include "certfilefactory.h" + +#ifndef TLS1_3_VERSION +#error TLS 1.3 support required. Upgrade to openssl >= 1.1.0 +#endif + +DEFINE_LOGGER(setup, "pvxs.ossl.init"); +DEFINE_LOGGER(stapling, "pvxs.stapling"); +DEFINE_LOGGER(watcher, "pvxs.certs.mon"); +DEFINE_LOGGER(io, "pvxs.ossl.io"); + +namespace pvxs { +namespace ossl { + +int ossl_verify(int preverify_ok, X509_STORE_CTX *x509_ctx) { + X509 *cert_ptr = X509_STORE_CTX_get_current_cert(x509_ctx); + if (preverify_ok) { + // cert passed initial inspection, now check if revocation status is required + if (!certs::CertStatusManager::statusMonitoringRequired(cert_ptr)) { + return preverify_ok; // No need to check status + } + + // Status monitoring required, now check revocation status + log_debug_println(watcher, "Current cert: %s\n", std::string(SB() << ShowX509{cert_ptr}).c_str()); + auto pva_ex_data = CertStatusExData::fromSSL_X509_STORE_CTX(x509_ctx); + + // Check if status monitoring is enabled + // TODO Verify with working group that this logic is correct + if (pva_ex_data->status_check_enabled) { + auto peer_status = pva_ex_data->getCachedPeerStatus(cert_ptr); + try { + // Get status if current status is non-existent or not valid + if (!peer_status || !peer_status->isValid()) { + peer_status = pva_ex_data->setCachedPeerStatus(cert_ptr, certs::CertStatusManager::getStatus(ossl_ptr(X509_dup(cert_ptr)))); + } + if (!peer_status->isGood()) { + return 0; // At least one cert is not good + } + } catch (certs::CertStatusNoExtensionException &e) { + log_err_printf(watcher, "Logic Error: Status monitored when not configured in cert: %s\n", std::string(SB() << ShowX509{cert_ptr}).c_str()); + exit(1); + } catch (std::runtime_error &e) { + log_warn_printf(watcher, "Unable to verify peer revocation status: %s\n", e.what()); + return 0; // We need to verify the peer status but can't so fail + } + } + } else { + auto err = X509_STORE_CTX_get_error(x509_ctx); + log_err_printf(io, "Unable to verify peer cert: %s : %s\n", X509_verify_cert_error_string(err), std::string(SB() << ShowX509{cert_ptr}).c_str()); + } + log_printf(io, preverify_ok ? Level::Debug : Level::Err, "TLS verify %s\n", preverify_ok ? "Ok" : "Reject"); + return preverify_ok; +} + +void ensureTrusted(const ossl_ptr &ca_cert, const ossl_ptr &CAs) { + // Create a new X509_STORE with trusted root CAs + ossl_ptr store(X509_STORE_new(), false); + if (!store) { + throw std::runtime_error("Failed to create X509_STORE to verify CA trust"); + } + + // Load trusted root CAs from a predefined location + if (X509_STORE_set_default_paths(store.get()) != 1) { + throw std::runtime_error("Failed to load system default CA certificates to verify CA trust"); + } + + // Set up a store context for verification + ossl_ptr ctx(X509_STORE_CTX_new(), false); + if (!ctx) { + throw std::runtime_error("Failed to create X509_STORE_CTX to verify CA trust"); + } + + if (X509_STORE_CTX_init(ctx.get(), store.get(), ca_cert.get(), CAs.get()) != 1) { + throw std::runtime_error("Failed to initialize X509_STORE_CTX to verify CA certificate"); + } + + // Set parameters for verification of the CA certificate + X509_STORE_CTX_set_flags(ctx.get(), + X509_V_FLAG_PARTIAL_CHAIN | // Succeed as soon as at least one intermediary is trusted + X509_V_FLAG_CHECK_SS_SIGNATURE | // Allow self-signed root CA + X509_V_FLAG_TRUSTED_FIRST // Check the trusted locations first + ); + if (X509_verify_cert(ctx.get()) != 1) { + throw std::runtime_error("Certificate is not trusted by this host"); + } +} + +namespace { + +constexpr int ossl_verify_depth = 5; + +// see NOTE in "man SSL_CTX_set_alpn_protos" +const unsigned char pva_alpn[] = "\x05pva/1"; + +struct OSSLGbl { + ossl_ptr libctx; + int SSL_CTX_ex_idx; +#ifdef PVXS_ENABLE_SSLKEYLOGFILE + std::ofstream keylog; + epicsMutex keylock; +#endif +} *ossl_gbl; + +#ifdef PVXS_ENABLE_SSLKEYLOGFILE +void sslkeylogfile_exit(void *) noexcept { + auto gbl = ossl_gbl; + try { + epicsGuard G(gbl->keylock); + if (gbl->keylog.is_open()) { + gbl->keylog.flush(); + gbl->keylog.close(); + } + } catch (std::exception &e) { + static bool once = false; + if (!once) { + fprintf(stderr, "Error while writing to SSLKEYLOGFILE\n"); + once = true; + } + } +} + +void sslkeylogfile_log(const SSL *, const char *line) noexcept { + auto gbl = ossl_gbl; + try { + epicsGuard G(gbl->keylock); + if (gbl->keylog.is_open()) { + gbl->keylog << line << '\n'; + gbl->keylog.flush(); + } + } catch (std::exception &e) { + static bool once = false; + if (!once) { + fprintf(stderr, "Error while writing to SSLKEYLOGFILE\n"); + once = true; + } + } +} +#endif // PVXS_ENABLE_SSLKEYLOGFILE + +void free_SSL_CTX_sidecar(void *, void *ptr, CRYPTO_EX_DATA *, int , long , void *) noexcept { + auto car = static_cast(ptr); + delete car; +} + +void OSSLGbl_init() { + ossl_ptr ctx(__FILE__, __LINE__, OSSL_LIB_CTX_new()); + // read $OPENSSL_CONF or eg. /usr/lib/ssl/openssl.cnf + (void)CONF_modules_load_file_ex(ctx.get(), NULL, "pvxs", CONF_MFLAGS_IGNORE_MISSING_FILE | CONF_MFLAGS_IGNORE_RETURN_CODES); + std::unique_ptr gbl{new OSSLGbl}; + gbl->SSL_CTX_ex_idx = SSL_CTX_get_ex_new_index(0, nullptr, nullptr, nullptr, free_SSL_CTX_sidecar); +#ifdef PVXS_ENABLE_SSLKEYLOGFILE + if (auto env = getenv("SSLKEYLOGFILE")) { + epicsGuard G(gbl->keylock); + gbl->keylog.open(env); + if (gbl->keylog.is_open()) { + epicsAtExit(sslkeylogfile_exit, nullptr); + log_warn_printf(setup, "TLS Debug Enabled: logging TLS secrets to %s\n", env); + } else { + log_err_printf(setup, "TLS Debug Disabled: Unable to open SSL key log file: %s\n", env); + } + } +#endif // PVXS_ENABLE_SSLKEYLOGFILE + ossl_gbl = gbl.release(); +} + +int ossl_alpn_select(SSL *, const unsigned char **out, unsigned char *outlen, const unsigned char *in, unsigned int inlen, void *) { + unsigned char *selected; + auto ret(SSL_select_next_proto(&selected, outlen, pva_alpn, sizeof(pva_alpn) - 1u, in, inlen)); + if (ret == OPENSSL_NPN_NEGOTIATED) { + *out = selected; + log_debug_printf(io, "TLS ALPN select%s", "\n"); + return SSL_TLSEXT_ERR_OK; + } else { // OPENSSL_NPN_NO_OVERLAP + log_err_printf(io, "TLS ALPN reject%s", "\n"); + return SSL_TLSEXT_ERR_ALERT_FATAL; // could fail soft w/ SSL_TLSEXT_ERR_NOACK + } +} + +/** + * @brief Verifies the key usage of a given certificate. + * + * This function checks the key usage extension of the specified certificate + * and verifies that the key usage flags match the intended purpose. + * If ssl_client is set to true, it will verify that the key usage includes + * the key encipherment flag. + * + * If ssl_client is set to false, it will verify + * that the key usage includes the digital signature flag. + * + * @param cert The X509 certificate to verify key usage for. + * @param ssl_client A flag indicating whether the certificate is for SSL + * client. + * @return Don't throw if the key usage is valid for the intended purpose, + * throw an exception otherwise. + */ +void verifyKeyUsage(const ossl_ptr &cert, + bool ssl_client) { // some early sanity checks + auto flags(X509_get_extension_flags(cert.get())); + auto kusage(X509_get_extended_key_usage(cert.get())); + + if (flags & EXFLAG_CA) throw std::runtime_error(SB() << "Found CA Certificate when End Entity expected"); + + if ((ssl_client && !(kusage & XKU_SSL_CLIENT)) || (!ssl_client && !(kusage & XKU_SSL_SERVER))) + throw std::runtime_error(SB() << "Extended Key Usage does not permit usage as a Secure PVAccesss " << (ssl_client ? "Client" : "Server")); + + log_debug_printf(setup, "Using%s cert %s\n", (flags & EXFLAG_SS) ? " self-signed" : "", std::string(SB() << ShowX509{cert.get()}).c_str()); +} + +/** + * @brief Extracts the certificate authorities from the provided CAs and + * adds them to the given context. + * + * java keytool adds an extra attribute to indicate that a certificate + * is trusted. However, PKCS12_parse() circa 3.1 does not know about + * this, and gives us all the certs. in one blob for us to sort through. + * + * We _assume_ that any root CA included in a keychain file is meant to + * be trusted. Otherwise, such a cert. could never appear in a valid + * chain. + * + * @param ctx the context to add the CAs to + * @param CAs the stack of X509 CA certificates + */ +void extractCAs(SSLContext &ctx, const ossl_shared_ptr &CAs) { + for (int i = 0, N = sk_X509_num(CAs.get()); i < N; i++) { + auto ca = sk_X509_value(CAs.get(), i); + + auto canSign(X509_check_ca(ca)); + auto flags(X509_get_extension_flags(ca)); + + if (canSign == 0 && i != 0) { + log_err_printf(setup, "non-CA certificate in keychain%s\n", ""); + log_err_printf(setup, "%s\n", (SB() << ShowX509{ca}).str().c_str()); + throw std::runtime_error(SB() << "non-CA certificate found in keychain"); + } + + if (flags & EXFLAG_SS) { // self-signed (aka. root) + assert(flags & EXFLAG_SI); // circa OpenSSL, self-signed implies self-issued + + log_debug_println(setup, "Trusting root CA %s\n", std::string(SB() << ShowX509{ca}).c_str()); + + // populate the context's trust store with the root cert + X509_STORE *trusted_store = SSL_CTX_get_cert_store(ctx.ctx); + if (!X509_STORE_add_cert(trusted_store, ca)) throw SSLError("X509_STORE_add_cert"); + } else { // signed by another CA + log_debug_println(setup, "Using untrusted/chain CA cert %s\n", std::string(SB() << ShowX509{ca}).c_str()); + // note: chain certs added this way are ignored unless SSL_BUILD_CHAIN_FLAG_UNTRUSTED is used + // appends SSL_CTX::cert::chain + } + if (!SSL_CTX_add0_chain_cert(ctx.ctx, ca)) throw SSLError("SSL_CTX_add0_chain_cert"); + + // TODO monitor this certificate status and disableTLS if becomes invalid and only continue if the status is good + } +} + +/** + * @brief Common setup for OpenSSL SSL context + * + * This function sets up the OpenSSL SSL context used for SSL/TLS communication. + * It configures the SSL method, whether it is for a client or a server, and the + * common configuration options. + * + * @param method The SSL_METHOD object representing the SSL method to use. + * @param ssl_client A boolean indicating whether the setup is for a client or a + * server. + * @param conf The common configuration object. + * + * @return SSLContext initialised appropriately - clients can have an empty + * context so that they can connect to ssl servers without having a certificate + */ +SSLContext ossl_setup_common(const SSL_METHOD *method, bool ssl_client, const impl::ConfigCommon &conf) { + impl::threadOnce<&OSSLGbl_init>(); + + // Initialise SSL subsystem and add our custom extensions (idempotent) + SSLContext::sslInit(); + + SSLContext tls_context; + tls_context.status_check_disabled = conf.tls_disable_status_check; + tls_context.stapling_disabled = conf.tls_disable_stapling; + tls_context.ctx = SSL_CTX_new_ex(ossl_gbl->libctx.get(), NULL, method); + if (!tls_context.ctx) throw SSLError("Unable to allocate SSL_CTX"); + + { + std::unique_ptr car{new CertStatusExData(!tls_context.status_check_disabled)}; + if (!SSL_CTX_set_ex_data(tls_context.ctx, ossl_gbl->SSL_CTX_ex_idx, car.get())) throw SSLError("SSL_CTX_set_ex_data"); + car.release(); // SSL_CTX_free() now responsible + } + +#ifdef PVXS_ENABLE_SSLKEYLOGFILE + // assert(!SSL_CTX_get_keylog_callback(ctx.ctx)); + (void)SSL_CTX_set_keylog_callback(tls_context.ctx, &sslkeylogfile_log); +#endif + + // TODO: SSL_CTX_set_options(), SSL_CTX_set_mode() ? + + // we mandate TLS >= 1.3 + (void)SSL_CTX_set_min_proto_version(tls_context.ctx, TLS1_3_VERSION); + (void)SSL_CTX_set_max_proto_version(tls_context.ctx, 0); // up to max. + + if (ssl_client && conf.tls_disabled) { + // For clients if tls is disabled then allow server to make a tls + // connection if it can but disable client side + return tls_context; + } + + if (conf.isTlsConfigured()) { + const std::string &filename = conf.tls_cert_filename, &password = conf.tls_cert_password; + auto key_filename = conf.tls_private_key_filename.empty() ? filename : conf.tls_private_key_filename; + auto key_password = conf.tls_private_key_password.empty() ? password : conf.tls_private_key_password; + + // get the key and certificate from the file or files + auto cert_data = certs::IdFileFactory::createReader(filename, password, key_filename, key_password)->getCertDataFromFile(); + if (cert_data.cert) { + // some early sanity checks + verifyKeyUsage(cert_data.cert, ssl_client); + } + + // sets SSL_CTX::cert + if (cert_data.cert && !SSL_CTX_use_certificate(tls_context.ctx, cert_data.cert.get())) throw SSLError("SSL_CTX_use_certificate"); + if (cert_data.key_pair && !SSL_CTX_use_PrivateKey(tls_context.ctx, cert_data.key_pair->pkey.get())) throw SSLError("SSL_CTX_use_certificate"); + + // extract CAs (intermediate and root) from PKCS12 bag + extractCAs(tls_context, cert_data.ca); + + if (cert_data.key_pair && cert_data.key_pair->pkey && !SSL_CTX_check_private_key(tls_context.ctx)) throw SSLError("invalid private key"); + + // Move cert to the context + if (cert_data.cert) { + auto ex_data = tls_context.ex_data(); + ex_data->cert = std::move(cert_data.cert); + tls_context.has_cert = true; + + // Build the certificate chain and set verification flags + if (!SSL_CTX_build_cert_chain(tls_context.ctx, SSL_BUILD_CHAIN_FLAG_CHECK)) // Check build chain + // if (!SSL_CTX_build_cert_chain(tls_context.ctx, SSL_BUILD_CHAIN_FLAG_UNTRUSTED)) // Flag untrusted in build chain + // if (!SSL_CTX_build_cert_chain(tls_context.ctx, 0)) // checks default operation + throw SSLError("invalid cert chain"); + + // If status check is disabled, set the certificate as valid immediately + if (tls_context.status_check_disabled) { + tls_context.cert_is_valid = true; + } + } + } + + { + /* wrt. SSL_VERIFY_CLIENT_ONCE + * TLS 1.3 does not support session renegotiation. + * Does allow server to re-request client cert. via CertificateRequest. + * However, no way for client to re-request server cert. + * So we don't bother with this, and instead for connection reset + * when new certs. loaded. + */ + int mode = SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE; + if (!ssl_client && conf.tls_client_cert_required == ConfigCommon::Require) { + mode |= SSL_VERIFY_FAIL_IF_NO_PEER_CERT; + log_debug_printf(setup, "This Secure PVAccess Server requires an X.509 client certificate%s", "\n"); + } + SSL_CTX_set_verify(tls_context.ctx, mode, &ossl_verify); + SSL_CTX_set_verify_depth(tls_context.ctx, ossl_verify_depth); + } + return tls_context; +} + +} // namespace + +/** + * @brief This is the callback that is made by the TLS handshake to add the server OCSP status to the payload + * + * @param tls_context the tls context to add the OCSP response to + */ +int serverOCSPCallback(SSL *ssl, pvxs::server::Server::Pvt *server) { + if (SSL_get_tlsext_status_type(ssl) != -1) { + // Should never be triggered. Because the callback should only be called when the client has requested stapling. + return SSL_TLSEXT_ERR_ALERT_WARNING; + } + + if (!server->current_status) { + log_warn_printf(stapling, "Server OCSP Stapling: No server status to staple%s\n", ""); + return SSL_TLSEXT_ERR_ALERT_FATAL; + } + + auto ocsp_data_ptr = (void *)server->current_status->ocsp_bytes.data(); + auto ocsp_data_len = server->current_status->ocsp_bytes.size(); + + if (!server->cached_ocsp_response || memcmp(ocsp_data_ptr, server->cached_ocsp_response, ocsp_data_len)) { + // if status has changed + if (server->cached_ocsp_response) { + OPENSSL_free(server->cached_ocsp_response); + } + server->cached_ocsp_response = OPENSSL_malloc(ocsp_data_len); + memcpy(server->cached_ocsp_response, ocsp_data_ptr, ocsp_data_len); + + if (SSL_set_tlsext_status_ocsp_resp(ssl, server->cached_ocsp_response, ocsp_data_len) != 1) { + log_warn_printf(stapling, "Server OCSP Stapling: unable to staple server status%s\n", ""); + return SSL_TLSEXT_ERR_ALERT_FATAL; + } else + log_info_printf(stapling, "Server OCSP Stapling: server status stapled%s\n", ""); + } + return SSL_TLSEXT_ERR_OK; +} + +/** + * @brief Staple server's ocsp response to the tls handshake + * @param ssl the ssl context + * @param arg + * @return + */ +void stapleOcspResponse(void *server_ptr, SSL *) { + auto server = (pvxs::server::Server::Pvt *)server_ptr; + SSL_CTX_set_tlsext_status_cb(server->tls_context.ctx, serverOCSPCallback); + SSL_CTX_set_tlsext_status_arg(server->tls_context.ctx, server); +} + +// Must be set up with correct values after OpenSSL initialisation to retrieve status PV from certs +int SSLContext::NID_PvaCertStatusURI = NID_undef; + +/** + * @brief Sets the peer status for the given serial number + * @param serial_number - Serial number + * @param status - Certificate status + * @return The peer status that was set + */ +std::shared_ptr CertStatusExData::setCachedPeerStatus(serial_number_t serial_number, const certs::CertificateStatus &status) { + return setCachedPeerStatus(serial_number, std::make_shared(status)); +} + +/** + * @brief Subscribes to cert status if required and not already monitoring + * @param cert_ptr - Certificate status to subscribe to + * @param fn - Function to call when the certificate status changes from good to bad or vice versa + */ +void CertStatusExData::subscribeToCertStatus(X509 *cert_ptr, std::function fn) { + auto serial_number = getSerialNumber(cert_ptr); + auto &cert_status_manager = peer_statuses[serial_number].cert_status_manager; + + if (cert_status_manager) return; // Already subscribed + + try { + // Duplicate the certificate + auto cert_to_monitor = ossl_ptr(X509_dup(cert_ptr)); + // Subscribe to the certificate status + cert_status_manager = certs::CertStatusManager::subscribe(std::move(cert_to_monitor), [=](certs::PVACertificateStatus status) { + Guard G(lock); + // Get the previous status + auto previous_status = getCachedPeerStatus(serial_number); + // Check if the previous status was good + auto was_good = previous_status && previous_status->isGood(); + // Get the current state while setting the cached peer status + auto current_status = setCachedPeerStatus(serial_number, status); + // Check if the current status is good + bool is_good = current_status && current_status->isGood(); + UnGuard U(G); + // If the state has changed, call the function + if (is_good != was_good) { + fn(is_good); + } + }); + } catch (...) { + } +} + +CertStatusExData *CertStatusExData::fromSSL_X509_STORE_CTX(X509_STORE_CTX *x509_ctx) { + SSL *ssl = (SSL *)X509_STORE_CTX_get_ex_data(x509_ctx, SSL_get_ex_data_X509_STORE_CTX_idx()); + return fromSSL(ssl); +} + +CertStatusExData *CertStatusExData::fromSSL(SSL *ssl) { + if (!ssl) { + return nullptr; + } + SSL_CTX *ssl_ctx = SSL_get_SSL_CTX(ssl); + return fromSSL_CTX(ssl_ctx); +} + +CertStatusExData *CertStatusExData::fromSSL_CTX(SSL_CTX *ssl_ctx) { + if (!ssl_ctx) { + return nullptr; + } + return static_cast(SSL_CTX_get_ex_data(ssl_ctx, ossl_gbl->SSL_CTX_ex_idx)); +} + +CertStatusExData *SSLContext::ex_data() const { return CertStatusExData::fromSSL_CTX(ctx); } + +const X509 *SSLContext::certificate0() const { + if (!ctx) throw std::invalid_argument("NULL"); + + auto car = static_cast(SSL_CTX_get_ex_data(ctx, ossl_gbl->SSL_CTX_ex_idx)); + return car->cert.get(); +} + +bool SSLContext::fill_credentials(PeerCredentials &C, const SSL *ctx) { + if (!ctx) throw std::invalid_argument("NULL"); + + if (auto cert = SSL_get0_peer_certificate(ctx)) { + PeerCredentials temp(C); // copy current as initial (don't overwrite isTLS) + auto subj = X509_get_subject_name(cert); + char name[64]; + if (subj && X509_NAME_get_text_by_NID(subj, NID_commonName, name, sizeof(name) - 1)) { + name[sizeof(name) - 1] = '\0'; + log_debug_printf(io, "Peer CN=%s\n", name); + temp.method = "x509"; + temp.account = name; + + // try to use root CA name to qualify authority + if (auto chain = SSL_get0_verified_chain(ctx)) { + auto N = sk_X509_num(chain); + X509 *root; + X509_NAME *rootName; + // last cert should be root CA + if (N && !!(root = sk_X509_value(chain, N - 1)) && !!(rootName = X509_get_subject_name(root)) && + X509_NAME_get_text_by_NID(rootName, NID_commonName, name, sizeof(name) - 1)) { + if (X509_check_ca(root) && (X509_get_extension_flags(root) & EXFLAG_SS)) { + temp.authority = name; + + } else { + log_warn_printf(io, "Last cert in peer chain is not root CA?!? %s\n", std::string(SB() << ossl::ShowX509{root}).c_str()); + } + } + } + } + + C = std::move(temp); + return true; + } else { + return false; + } +} + +SSLContext SSLContext::for_client(const impl::ConfigCommon &conf) { + auto ctx(ossl_setup_common(TLS_client_method(), true, conf)); + + if (0 != SSL_CTX_set_alpn_protos(ctx.ctx, pva_alpn, sizeof(pva_alpn) - 1)) + throw SSLError("Unable to agree on Application Layer Protocol to use: Both sides should use pva/1"); + + return ctx; +} + +SSLContext SSLContext::for_server(const impl::ConfigCommon &conf) { + auto ctx(ossl_setup_common(TLS_server_method(), false, conf)); + + SSL_CTX_set_alpn_select_cb(ctx.ctx, &ossl_alpn_select, nullptr); + + return ctx; +} + +SSLError::SSLError(const std::string &msg) + : std::runtime_error([&msg]() -> std::string { + std::ostringstream strm; + const char *file = nullptr; + int line = 0; + const char *data = nullptr; + int flags = 0; + while (auto err = ERR_get_error_all(&file, &line, nullptr, &data, &flags)) { + strm << file << ':' << line << ':' << ERR_reason_error_string(err); + if (data && (flags & ERR_TXT_STRING)) strm << ':' << data; + strm << ", "; + } + strm << msg; + return strm.str(); + }()) {} + +SSLError::~SSLError() = default; + +std::ostream &operator<<(std::ostream &strm, const ShowX509 &cert) { + if (cert.cert) { + auto name = X509_get_subject_name(cert.cert); + auto issuer = X509_get_issuer_name(cert.cert); + assert(name); + ossl_ptr io(__FILE__, __LINE__, BIO_new(BIO_s_mem())); + (void)BIO_printf(io.get(), "subject:"); + (void)X509_NAME_print(io.get(), name, 1024); + (void)BIO_printf(io.get(), " issuer:"); + (void)X509_NAME_print(io.get(), issuer, 1024); + if (auto atm = X509_get0_notBefore(cert.cert)) { + (void)BIO_printf(io.get(), " from: "); + ASN1_TIME_print(io.get(), atm); + } + if (auto atm = X509_get0_notAfter(cert.cert)) { + (void)BIO_printf(io.get(), " until: "); + ASN1_TIME_print(io.get(), atm); + } + { + char *str = nullptr; + if (auto len = BIO_get_mem_data(io.get(), &str)) { + strm.write(str, len); + } + } + } else { + strm << "NULL"; + } + return strm; +} + +} // namespace ossl +} // namespace pvxs diff --git a/src/openssl.h b/src/openssl.h new file mode 100644 index 000000000..8141713d7 --- /dev/null +++ b/src/openssl.h @@ -0,0 +1,332 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_OPENSSL_H +#define PVXS_OPENSSL_H + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#endif + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "ownedptr.h" +#include "p12filewatcher.h" + +typedef epicsGuard Guard; +typedef epicsGuardRelease UnGuard; +typedef uint64_t serial_number_t; + +// EPICS OID for "validTillRevoked" extension: +// TODO Register this unassigned OID for EPICS +// "1.3.6.1.4.1" OID prefix for custom OIDs +// "37427" DTMF for "EPICS" +#define NID_PvaCertStatusURIID "1.3.6.1.4.1.37427.1" +#define SN_PvaCertStatusURI "ASN.1 - PvaCertStatusURI" +#define LN_PvaCertStatusURI "EPICS PVA Certificate Status URI" + +namespace pvxs { + +namespace client { +struct Config; +} +namespace server { +struct Config; +} +namespace certs { +struct CertificateStatus; +class CertStatusManager; +template +struct cert_status_delete; + +template +using cert_status_ptr = ossl_shared_ptr>; +} + +namespace ssl { +constexpr uint16_t kForClient = 0x01; +constexpr uint16_t kForServer = 0x02; +constexpr uint16_t kForIntermediateCa = 0x04; +constexpr uint16_t kForCMS = 0x08; +constexpr uint16_t kForCa = 0x10; + +constexpr uint16_t kForClientAndServer = kForClient | kForServer; +constexpr uint16_t kAnyServer = kForCMS | kForServer; + +#define IS_USED_FOR_(USED, USAGE) ((USED & (USAGE)) == USAGE) +#define IS_FOR_A_SERVER_(USED) ((USED & (ssl::kAnyServer)) != 0x00) +} // namespace ssl + +struct PeerCredentials; +namespace ossl { + +PVXS_API int ossl_verify(int preverify_ok, X509_STORE_CTX* x509_ctx); +PVXS_API void ensureTrusted(const ossl_ptr &ca_cert, const ossl_ptr &CAs); + +struct PVXS_API SSLError : public std::runtime_error { + explicit SSLError(const std::string& msg); + virtual ~SSLError(); +}; + +/** + * @brief Contains peer status and peer status monitor + * + */ +struct SSLPeerStatus { + std::shared_ptr status; + certs::cert_status_ptr cert_status_manager; +}; + +struct CertStatusExData { + epicsMutex lock; // To lock changes to peer statuses + ossl_ptr cert; + const bool status_check_enabled; + CertStatusExData(bool status_check_enabled) : status_check_enabled(status_check_enabled) {} + + std::map peer_statuses; + + static CertStatusExData* fromSSL_X509_STORE_CTX(X509_STORE_CTX* x509_ctx); + static CertStatusExData* fromSSL_CTX(SSL_CTX* ssl); + static CertStatusExData* fromSSL(SSL* ssl); + + /** + * @brief Returns the serial number for the given certificate + * @param cert_ptr - Certificate + * @return The serial number + */ + static inline serial_number_t getSerialNumber(X509 *cert_ptr) { + ASN1_INTEGER *serial = X509_get_serialNumber(cert_ptr); + ossl_ptr bn(ASN1_INTEGER_to_BN(serial, nullptr), false); + if (!bn) { + return 0; + } + + if (BN_num_bytes(bn.get()) > sizeof(uint64_t)) { + return 0; + } + + return (serial_number_t)BN_get_word(bn.get()); + } + + /** + * @brief Sets the peer status for the certificate for the given certificate + * @param cert_ptr - Certificate + * @param status - Certificate status + * @return The peer status that was set + */ + inline std::shared_ptr setCachedPeerStatus(X509 *cert_ptr, const certs::CertificateStatus &status) { + return setCachedPeerStatus(getSerialNumber(cert_ptr), status); + } + + /** + * @brief Sets the peer status for the given serial number + * @param serial_number - Serial number + * @param status - Certificate status + * @return The peer status that was set + */ + std::shared_ptr setCachedPeerStatus(serial_number_t serial_number, const certs::CertificateStatus &status); + + /** + * @brief Sets the peer status for the given certificate + * @param cert_ptr - Certificate + * @param status - Certificate status + * @return The peer status that was set + */ + inline std::shared_ptr setCachedPeerStatus(X509 *cert_ptr, std::shared_ptr status) { + return setCachedPeerStatus(getSerialNumber(cert_ptr), status); + } + + /** + * @brief Sets the peer status for the given serial number + * @param serial_number - Serial number + * @param status - Certificate status + * @return The peer status that was set + */ + inline std::shared_ptr setCachedPeerStatus(serial_number_t serial_number, const std::shared_ptr status) { + Guard G(lock); + peer_statuses[serial_number].status = status; + return peer_statuses[serial_number].status; + } + + /** + * @brief Returns the currently cached peer status if any. Null if none cached + * @param cert_ptr - Certificate + * @return The the cached peer status + */ + inline std::shared_ptr getCachedPeerStatus(X509 *cert_ptr) const { + return getCachedPeerStatus(getSerialNumber(cert_ptr)); + } + + /** + * @brief Returns the currently cached peer status if any. Null if none cached + * @param serial_number - Serial number + * @return The cached peer status + */ + inline std::shared_ptr getCachedPeerStatus(const serial_number_t serial_number) const { + auto it = peer_statuses.find(serial_number); + if (it != peer_statuses.end()) { + return it->second.status; + } + return nullptr; + } + + /** + * @brief Subscribes to peer status if required and not already monitoring + * @param cert_ptr - peer certificate status to subscribe to + * @param fn - Function to call when the peer status changes from good to bad or vice versa + */ + void subscribeToCertStatus(X509 *cert_ptr, std::function fn); +}; + +struct ShowX509 { + const X509* cert; +}; + +std::ostream& operator<<(std::ostream& strm, const ShowX509& cert); + +/** + * @brief SSL context for TLS communication + * + * This struct encapsulates the OpenSSL SSL_CTX and related state for managing + * TLS connections. It includes flags for certificate validity, status checking, + * and stapling. The context can be used for both client and server connections, + * and maintains a map of peer statuses for multiple connections. + * + * Key components: + * - SSL_CTX* ctx: The OpenSSL SSL context + * - Flags for certificate and status checking states + * - A map of socket file descriptors to SSLPeerStatus for managing multiple connections + * - Static methods for creating client and server contexts + * + * This struct is central to PVXS's TLS implementation, handling context creation, + * certificate management, and peer status tracking. + */ +struct SSLContext { + epicsMutex lock; // To lock changes to context state that happen as a result of changes to certificate status + static PVXS_API int NID_PvaCertStatusURI; + SSL_CTX* ctx = nullptr; + bool has_cert{false}; // set when a certificate has been established + bool cert_is_valid{false}; // To signal that cert is valid when we have received the status for the certificate + bool status_check_disabled{false}; + bool stapling_disabled{false}; + + PVXS_API + static SSLContext for_client(const impl::ConfigCommon& conf); + PVXS_API + static SSLContext for_server(const impl::ConfigCommon& conf); + + CertStatusExData* ex_data() const; + + SSLContext() = default; + inline SSLContext(const SSLContext& o) + : ctx(o.ctx), + has_cert(o.has_cert), + cert_is_valid(o.cert_is_valid), + status_check_disabled(o.status_check_disabled), + stapling_disabled(o.stapling_disabled) { + if (ctx) { + auto ret(SSL_CTX_up_ref(ctx)); + assert(ret == 1); // can up_ref actually fail? + } + } + inline SSLContext(SSLContext& o) noexcept + : ctx(o.ctx), + has_cert(o.has_cert), + cert_is_valid(o.cert_is_valid), + status_check_disabled(o.status_check_disabled), + stapling_disabled(o.stapling_disabled) { + o.ctx = nullptr; + } + inline ~SSLContext() { + SSL_CTX_free(ctx); // If ctx is NULL nothing is done. + } + inline SSLContext& operator=(const SSLContext& o) { + if (o.ctx) { + auto ret(SSL_CTX_up_ref(o.ctx)); + assert(ret == 1); // can up_ref actually fail? + } + SSL_CTX_free(ctx); + ctx = o.ctx; + has_cert = o.has_cert; + cert_is_valid = o.cert_is_valid; + status_check_disabled = o.status_check_disabled; + stapling_disabled = o.stapling_disabled; + return *this; + } + inline SSLContext& operator=(SSLContext&& o) { + SSL_CTX_free(ctx); + ctx = o.ctx; + has_cert = o.has_cert; + cert_is_valid = o.cert_is_valid; + status_check_disabled = o.status_check_disabled; + stapling_disabled = o.stapling_disabled; + o.ctx = nullptr; + return *this; + } + + /** + * @brief Initializes the SSL library and sets up the custom certificate status URI OID + * Uses the singleton pattern to ensure that the SSL library is initialized only once, + * keyed off NID_PvaCertStatusURI being undefined. + * + * This is idempotent. It can be called multiple times, but will not re-initialize the SSL library. + * + * It will do all the one time SSL library initialization that is required, inluding + * SSL_library_init(), OpenSSL_add_all_algorithms(), ERR_load_crypto_strings(), + * OpenSSL_add_all_ciphers(), and OpenSSL_add_all_digests(). + * + * It will also create and register the custom certificate status URI OID. + */ + static inline void sslInit() { + // Initialize SSL + if (NID_PvaCertStatusURI == NID_undef) { + SSL_library_init(); + OpenSSL_add_all_algorithms(); + ERR_load_crypto_strings(); + OpenSSL_add_all_ciphers(); + OpenSSL_add_all_digests(); + NID_PvaCertStatusURI = OBJ_create(NID_PvaCertStatusURIID, SN_PvaCertStatusURI, LN_PvaCertStatusURI); + if (NID_PvaCertStatusURI == NID_undef) { + throw std::runtime_error("Failed to create NID for " SN_PvaCertStatusURI ": " LN_PvaCertStatusURI); + } + } + } + + explicit operator bool() const { return ctx; } + + const X509* certificate0() const; + + static bool fill_credentials(PeerCredentials& cred, const SSL* ctx); +}; + +PVXS_API void stapleOcspResponse(void* server, SSL* ssl); + +struct OCSPStapleData { + size_t size; + uint8_t ocsp_response[]; +}; + +} // namespace ossl +} // namespace pvxs + +#endif // PVXS_OPENSSL_H diff --git a/src/osiSockExt.h b/src/osiSockExt.h index 6bd9ea8e4..d22f9bf93 100644 --- a/src/osiSockExt.h +++ b/src/osiSockExt.h @@ -26,6 +26,9 @@ #endif namespace pvxs { +namespace impl { +struct ConfigCommon; +} // namespace impl PVXS_API void osiSockAttachExt(); @@ -142,10 +145,15 @@ struct PVXS_API SockEndpoint { // if mcast, then output TTL and interface int ttl=-1; std::string iface; + enum ep_t : uint8_t { + Plain, // "classic" PVA in the clear + TLS, // PVA over TLS + } scheme = Plain; SockEndpoint() = default; - SockEndpoint(const char* ep, uint16_t defport=0); - SockEndpoint(const std::string& ep, uint16_t defport=0) :SockEndpoint(ep.c_str(), defport) {} + SockEndpoint(const char* ep, const impl::ConfigCommon *conf = nullptr, uint16_t defport=0); + SockEndpoint(const std::string& ep, const impl::ConfigCommon *conf = nullptr, uint16_t defport=0) + :SockEndpoint(ep.c_str(), conf, defport) {} explicit SockEndpoint(const SockAddr& addr) :addr(addr) {} MCastMembership resolve() const; diff --git a/src/ownedptr.h b/src/ownedptr.h new file mode 100644 index 000000000..b3782786c --- /dev/null +++ b/src/ownedptr.h @@ -0,0 +1,261 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +#ifndef PVXS_OWNED_PTR_H_ +#define PVXS_OWNED_PTR_H_ + +#ifdef PVXS_ENABLE_OPENSSL +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sqlite3.h" +#endif +#include "utilpvt.h" + +namespace pvxs { + +#ifdef PVXS_ENABLE_OPENSSL +template +struct ssl_delete; + +template +struct ssl_delete_all; + +template +struct sqlite_delete; +#endif + +template +struct file_delete; + +#define DEFINE_FILE_DELETER_FOR_(TYPE) \ + template <> \ + struct file_delete { \ + inline void operator()(TYPE *base_pointer) { \ + if (base_pointer) fclose(base_pointer); \ + } \ + } + +#ifdef PVXS_ENABLE_OPENSSL +#define DEFINE_SSL_DELETER_FOR_(TYPE) \ + template <> \ + struct ssl_delete { \ + inline void operator()(TYPE *base_pointer) { \ + if (base_pointer) TYPE##_free(base_pointer); \ + } \ + } + +#define DEFINE_SSL_DELETER_ALL_FOR_(TYPE) \ + template <> \ + struct ssl_delete_all { \ + inline void operator()(TYPE *base_pointer) { \ + if (base_pointer) TYPE##_free_all(base_pointer); \ + } \ + } + +#define DEFINE_SQLITE_DELETER_FOR_(TYPE) \ + template <> \ + struct sqlite_delete { \ + inline void operator()(TYPE *base_pointer) { \ + if (base_pointer) sqlite3_close(base_pointer); \ + } \ + } + +#define DEFINE_SSL_STACK_DELETER_FOR_(TYPE) \ + template <> \ + struct ssl_delete { \ + inline void operator()(STACK_OF(TYPE) * base_pointer) { \ + if (base_pointer) sk_##TYPE##_free(base_pointer); \ + } \ + } + +#define DEFINE_SSL_INFO_STACK_DELETER_FOR_(TYPE) \ + template <> \ + struct ssl_delete { \ + inline void operator()(STACK_OF(TYPE) * base_pointer) { \ + if (base_pointer) sk_##TYPE##_pop_free(base_pointer, TYPE##_free); \ + } \ + } + +#define DEFINE_OPENSSL_DELETER_FOR_(TYPE) \ + template <> \ + struct ssl_delete { \ + inline void operator()(TYPE *base_pointer) { \ + if (base_pointer) OPENSSL_free(base_pointer); \ + } \ + } +#endif + +#define DEFINE_BIGNUM_DELETER_FOR_(TYPE) \ + template <> \ + struct ssl_delete { \ + inline void operator()(TYPE *base_pointer) { \ + if (base_pointer) BN_free(base_pointer); \ + } \ + } + +DEFINE_FILE_DELETER_FOR_(FILE); + +#ifdef PVXS_ENABLE_OPENSSL +DEFINE_BIGNUM_DELETER_FOR_(BIGNUM); +DEFINE_OPENSSL_DELETER_FOR_(char); +DEFINE_OPENSSL_DELETER_FOR_(unsigned char); +DEFINE_SQLITE_DELETER_FOR_(sqlite3); +DEFINE_SSL_DELETER_ALL_FOR_(BIO); +DEFINE_SSL_DELETER_FOR_(ASN1_OBJECT); +DEFINE_SSL_DELETER_FOR_(ASN1_TIME); +DEFINE_SSL_DELETER_FOR_(BIO); +DEFINE_SSL_DELETER_FOR_(EVP_MD_CTX); +DEFINE_SSL_DELETER_FOR_(EVP_PKEY); +DEFINE_SSL_DELETER_FOR_(EVP_PKEY_CTX); +DEFINE_SSL_DELETER_FOR_(OCSP_BASICRESP); +DEFINE_SSL_DELETER_FOR_(OCSP_CERTID); +DEFINE_SSL_DELETER_FOR_(OCSP_REQUEST); +DEFINE_SSL_DELETER_FOR_(OCSP_RESPONSE); +DEFINE_SSL_DELETER_FOR_(OSSL_LIB_CTX); +DEFINE_SSL_DELETER_FOR_(PKCS12); +DEFINE_SSL_DELETER_FOR_(SSL); +DEFINE_SSL_DELETER_FOR_(SSL_CTX); +DEFINE_SSL_DELETER_FOR_(X509); +DEFINE_SSL_DELETER_FOR_(X509_ATTRIBUTE); +DEFINE_SSL_DELETER_FOR_(X509_EXTENSION); +DEFINE_SSL_DELETER_FOR_(X509_NAME); +DEFINE_SSL_DELETER_FOR_(X509_STORE); +DEFINE_SSL_DELETER_FOR_(X509_STORE_CTX); +DEFINE_SSL_INFO_STACK_DELETER_FOR_(X509_INFO); +DEFINE_SSL_STACK_DELETER_FOR_(X509); +DEFINE_SSL_STACK_DELETER_FOR_(X509_ATTRIBUTE); +#endif + +#undef DEFINE_FILE_DELETER_FOR_ +#ifdef PVXS_ENABLE_OPENSSL +#undef DEFINE_SSL_DELETER_FOR_ +#undef DEFINE_SSL_DELETER_ALL_FOR_ +#undef DEFINE_SQLITE_DELETER_FOR_ +#undef DEFINE_SSL_STACK_DELETER_FOR_ +#undef DEFINE_OPENSSL_DELETER_FOR_ +#endif + +/** + * @class OwnedPtr + * @brief A smart pointer class that owns and manages the lifetime of a + * dynamically allocated object. + * + * OwnedPtr is a derived class of std::unique_ptr, providing additional + * functionality for managing the lifetime of dynamically allocated objects. It + * is similar to std::unique_ptr, but with a few modifications. Notably it + * allows for acquiring a new managed object (and this releasing the currently + * managed one). This is used when a function takes a pointer to a place to + * store a dynamically generated object that we want to manage. See below for + * more details. + * + * @tparam T The type of the object to manage. + * @tparam D The deleter type. + */ +template +struct OwnedPtr : public std::unique_ptr { + typedef std::unique_ptr base_t; + + constexpr OwnedPtr() noexcept {} + constexpr OwnedPtr(std::nullptr_t) noexcept : base_t(nullptr) {} + explicit OwnedPtr(const char *file, int line, T *ptr) : base_t(ptr) { + if (!*this) throw loc_bad_alloc(file, line); + } + explicit OwnedPtr(T *ptr, bool fail_on_null = true) : base_t(ptr) { + if (fail_on_null && !*this) + throw loc_bad_alloc(__FILE__, __LINE__); + } + + // When we have a function that sets a given argument pointer to a pointer + // to point to some stored value (T) that we don't manage on return, but we + // want to store this new pointed to pointer in the OwnedPtr. (e.g. void + // someFunction(T** pp_result) In this case we need to replace the pointer + // value of the OwnedPtr with the pointed to by the returned value. To do + // this for OwnedPtr variable `x` do the following: + // OwnedPtr x; + // someFunction(x.acquire()); + struct Acquisition { + base_t *owned; + T *ptr = nullptr; + + operator T **() { return &ptr; } + constexpr Acquisition(base_t *owned) : owned(owned) {} + constexpr Acquisition(OwnedPtr *owned) : owned(owned) {} + ~Acquisition() { owned->reset(ptr); } + }; + + Acquisition acquire() { return Acquisition{this}; } +}; + +// Use OwnedPtr to define a manager for a SSL object with all the custom SSL +// deleters defined above +using file_ptr = OwnedPtr>; + +#ifdef PVXS_ENABLE_OPENSSL +template +using ossl_ptr = OwnedPtr>; + +template +using ossl_ptr_all = OwnedPtr>; + +using sql_ptr = OwnedPtr>; + +/** + * An SSL Owned pointer. This is a managed shared pointer that + * will use SSL deleters to delete the managed object when the last reference to + * them goes out of scope and the manager is being deleted. + * + * Uses the DEFINE_SSL_DELETER_FOR_ macros to define the custom deleters for any + * class you want to manage in this way. e.g. `DEFINE_SSL_DELETER_FOR_(X509)` + * + * Example usage: + * @code + * // create a managed X.509 certificate with a constructor + * ossl_shared_ptr cert(PEM_read_bio_X509(bio.get(), NULL, NULL, NULL)); + * @endcode + * + * Note: Acquisition does not make sense for shared objects because you should + * never change the object pointed to by the shared object manager. That's the + * whole point. + * + * @tparam T SSL the type to manage + * @tparam D the deleter, either your custom deleter or defaults to + * the custom SSL deleters defined above + */ +template > +class ossl_shared_ptr : public std::shared_ptr { + public: + using base_t = std::shared_ptr; + + ossl_shared_ptr() = default; + ossl_shared_ptr(std::nullptr_t np) : base_t(np) {} + explicit ossl_shared_ptr(const char *file, int line, T *ptr) : base_t(ptr, D()) { + if (!*this) throw loc_bad_alloc(file, line); + } + explicit ossl_shared_ptr(T *ptr, D d = D()) : base_t(ptr, d) { + if (!*this) throw loc_bad_alloc(__FILE__, __LINE__); + } +}; + +// Helper function to create ossl_shared_ptr while removing const qualifier +template +ossl_shared_ptr make_ossl_shared_ptr(const T* ptr) { + return ossl_shared_ptr(const_cast(ptr)); +} + +#endif + +} // namespace pvxs +#endif // PVXS_OWNED_PTR_H_ diff --git a/src/p12filewatcher.h b/src/p12filewatcher.h new file mode 100644 index 000000000..4a695ba77 --- /dev/null +++ b/src/p12filewatcher.h @@ -0,0 +1,107 @@ +#ifndef PVXS_P12FILEWATCHER_H_ +#define PVXS_P12FILEWATCHER_H_ + +#include +#include + +#ifdef _WIN32 +#include +#endif + +#include + +#include + +namespace pvxs { +namespace certs { + +class P12FileWatcher { + private: + bool running{false}; + + public: + inline bool isRunning() { return true; }; + inline void stop() { running = false; }; + P12FileWatcher(logger &logger, const std::vector &paths_to_watch, const std::function &reconfigure_fn) + : logger_(logger), paths_to_watch_(paths_to_watch), reconfigure_fn_(reconfigure_fn) { + log_debug_printf(logger_, "File Watcher Event: %s\n", "Initializing"); + // Initialize the last write times + last_write_times_.resize(paths_to_watch_.size(), 0); + for (auto i = 0; i < paths_to_watch_.size(); ++i) { + if (!paths_to_watch_[i].empty()) { + try { + last_write_times_[i] = getFileModificationTime(paths_to_watch_[i]); + } catch (...) { + } + } + } + running = true; + log_debug_printf(logger, "File Watcher Event: %s\n", "Initialised"); + } + + inline ~P12FileWatcher() {}; + + inline void checkFileStatus() { + log_debug_printf(logger_, "File Watcher Event: %s\n", "Wake up"); + for (size_t i = 0; i < paths_to_watch_.size(); ++i) { + if (paths_to_watch_[i].empty()) continue; + time_t current_write_time; + try { + current_write_time = getFileModificationTime(paths_to_watch_[i]); + } catch (...) { + if (last_write_times_[i] != 0) { + log_debug_printf(logger_, "File Watcher: %s file was deleted\n", paths_to_watch_[i].c_str()); + last_write_times_[i] = current_write_time; + reconfigure_fn_(false); + break; + } + continue; + } + if (current_write_time != last_write_times_[i]) { + log_debug_printf(logger_, "File Watcher: %s file was updated\n", paths_to_watch_[i].c_str()); + last_write_times_[i] = current_write_time; + reconfigure_fn_(true); + break; + } + } + log_debug_printf(logger_, "File Watcher Event: %s\n", "Sleep"); + } + + private: + logger &logger_; + const std::vector paths_to_watch_; + std::function reconfigure_fn_; + std::vector last_write_times_; + + static inline time_t getFileModificationTime(const std::string &path) { +#ifdef _WIN32 + WIN32_FILE_ATTRIBUTE_DATA file_info; + if (GetFileAttributesEx(path.c_str(), GetFileExInfoStandard, &file_info)) { + SYSTEMTIME st; + FileTimeToSystemTime(&file_info.ftLastWriteTime, &st); + std::tm t = {}; + t.tm_year = st.wYear - 1900; + t.tm_mon = st.wMonth - 1; + t.tm_mday = st.wDay; + t.tm_hour = st.wHour; + t.tm_min = st.wMinute; + t.tm_sec = st.wSecond; + return std::mktime(&t); + } else { + throw std::runtime_error("Could not get file attributes"); + } +#else + struct stat file_info {}; + if (stat(path.c_str(), &file_info) == 0) { + return file_info.st_mtime; + } else { + throw std::runtime_error("Could not stat file"); + } +#endif + } +}; + +} // namespace certs +} // namespace pvxs + +#endif // PVXS_P12FILEWATCHER_H_ diff --git a/src/pvxs/client.h b/src/pvxs/client.h index 2a5383efa..55dd1207a 100644 --- a/src/pvxs/client.h +++ b/src/pvxs/client.h @@ -23,12 +23,22 @@ #include #include +#ifdef PVXS_ENABLE_OPENSSL +#include "openssl.h" +#endif + namespace pvxs { namespace client { class Context; struct Config; +//! Identity of a server. +//! +//! See pvxs::PeerCredentials +//! @since UNRELEASED +typedef PeerCredentials ServerCredentials; + //! Operation failed because of connection to server was lost struct PVXS_API Disconnect : public std::runtime_error { @@ -54,14 +64,25 @@ struct PVXS_API Finished : public Disconnect virtual ~Finished(); }; -//! For monitor only. Subscription has (re)connected. +//! Indication of connection to a server struct PVXS_API Connected : public std::runtime_error { - Connected(const std::string& peerName); + Connected(const std::string& peerName, + const epicsTime& time, + const std::shared_ptr& cred); + Connected(const std::string& peerName, + const std::shared_ptr& cred) + :Connected(peerName, epicsTime::getCurrent(), cred) // legacy + {} virtual ~Connected(); + //! Server IP address const std::string peerName; + //! Local time of connection const epicsTime time; + //! Identity of server. + //! @since UNRELEASED + const std::shared_ptr cred; }; struct PVXS_API Interrupted : public std::runtime_error @@ -306,10 +327,25 @@ class PVXS_API Context { * Shorthand for @code Config::fromEnv().build() @endcode. * @since 0.2.1 */ - static - Context fromEnv(); +#ifndef PVXS_ENABLE_OPENSSL + static Context fromEnv(); +#else + Context(const Config &, const std::function&); + static Context fromEnv(const bool tls_disabled = false); + static Context forCMS(); + + /** Apply (in part) updated configuration + * + * Currently, only updates TLS configuration. Causes all in-progress + * Operations to be disconnected. + * + * @since UNRELEASED + */ + void reconfigure(const Config&); +#endif // PVXS_ENABLE_OPENSSL //! effective config of running client + //! @since UNRELEASED Reference invalidated by a call to reconfigure() const Config& config() const; /** Force close the client. @@ -920,7 +956,7 @@ class ConnectBuilder std::shared_ptr ctx; std::string _pvname; std::string _server; - std::function _onConn; + std::function _onConn; std::function _onDis; bool _syncCancel = true; public: @@ -931,7 +967,16 @@ class ConnectBuilder {} //! Handler to be invoked when channel becomes connected. - ConnectBuilder& onConnect(std::function&& cb) { _onConn = std::move(cb); return *this; } + //! @since UNRELEASED + ConnectBuilder& onConnect(std::function&& cb) + { _onConn = std::move(cb); return *this; } + //! Handler to be invoked when channel becomes connected. + //! @since UNRELEASED Prefer void(const Connected&) in new code. + ConnectBuilder& onConnect(std::function&& cb) + { + _onConn = [cb](const Connected&) { cb(); }; + return *this; + } //! Handler to be invoked when channel becomes disconnected. ConnectBuilder& onDisconnect(std::function&& cb) { _onDis = std::move(cb); return *this; } @@ -1002,7 +1047,8 @@ class DiscoverBuilder }; DiscoverBuilder Context::discover(std::function && fn) { return DiscoverBuilder(pvt, std::move(fn)); } -struct PVXS_API Config { +struct PVXS_API Config : public impl::ConfigCommon { + /** List of unicast, multicast, and broadcast addresses to which search requests will be sent. * * Entries may take the forms: @@ -1021,32 +1067,27 @@ struct PVXS_API Config { //! @since 0.2.0 std::vector nameServers; - //! UDP port to bind. Default is 5076. May be zero, cf. Server::config() to find allocated port. - unsigned short udp_port = 5076; - //! Default TCP port for name servers - //! @since 0.2.0 - unsigned short tcp_port = 5075; - //! Whether to extend the addressList with local interface broadcast addresses. (recommended) bool autoAddrList = true; - //! Inactivity timeout interval for TCP connections. (seconds) - //! @since 0.2.0 - double tcpTimeout = 40.0; - private: bool BE = EPICS_BYTE_ORDER==EPICS_ENDIAN_BIG; bool UDP = true; public: +#ifndef PVXS_ENABLE_OPENSSL // compat static inline Config from_env() { return Config{}.applyEnv(); } - //! Default configuration using process environment - static inline Config fromEnv() { return Config{}.applyEnv(); } - + static inline Config fromEnv() { return Config{}.applyEnv(); } //! update using defined EPICS_PVA* environment variables - Config& applyEnv(); + Config &applyEnv(); +#else + static inline Config from_env(const bool tls_disabled = false, const ConfigTarget target = CLIENT) { return Config{}.applyEnv(tls_disabled, target); } + static inline Config fromEnv(const bool tls_disabled = false, const ConfigTarget target = CLIENT) { return Config{}.applyEnv(tls_disabled, target); } + Config &applyEnv(const bool tls_disabled = false, const ConfigTarget target = CLIENT); + Config &applyEnv(const bool tls_disabled = false); +#endif // PVXS_ENABLE_OPENSSL typedef std::map defs_t; //! update with definitions as with EPICS_PVA* environment variables @@ -1080,6 +1121,8 @@ struct PVXS_API Config { inline Config& overrideShareUDP(bool share) { UDP = share; return *this; } inline bool shareUDP() const { return UDP; } #endif + + void fromDefs(Config& self, const std::map& defs, bool useenv); }; PVXS_API diff --git a/src/pvxs/config.h b/src/pvxs/config.h new file mode 100644 index 000000000..63ec717d8 --- /dev/null +++ b/src/pvxs/config.h @@ -0,0 +1,264 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +#ifndef PVXS_CONFIG_H +#define PVXS_CONFIG_H + +#ifdef __linux__ +#include +#elif defined(__APPLE__) || defined(__FreeBSD__) +#include + +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef __unix__ +#include +#endif +#include +#include +#include + +#include + +#include + +#include + +#include "osiFileName.h" + +namespace pvxs { +namespace impl { + +struct PVXS_API ConfigCommon { + bool is_initialized{false}; + enum ConfigTarget { CLIENT, SERVER, GATEWAY, CMS, OCSP } config_target = CLIENT; + + virtual ~ConfigCommon() = 0; + +#ifdef PVXS_ENABLE_OPENSSL + /** + * @brief Convert given path to expand tilde, dot and dot-dot at beginning + * @param path the containing tilde, dot and/or dot-dot + * @return the expanded path + */ + std::string inline convertPath(std::string &path) { + std::string abs_path; + + if (!path.empty()) { + if (path[0] == '~') { + char const *home = getenv("HOME"); + if (home || ((home = getenv("USERPROFILE")))) { + abs_path = home + path.substr(1); + } +#ifdef __unix__ + else { + auto pw = getpwuid(getuid()); + if (pw) abs_path = pw->pw_dir + path.substr(1); + } +#endif + } else if (path[0] == '.') { + char temp[PATH_MAX]; + if (getcwd(temp, sizeof(temp)) != NULL) { + if (path.size() > 1 && path[1] == '.') { + // Handle '..' to get parent directory + abs_path = dirname(temp); + // Append the rest of the path after the '..' + abs_path += path.substr(2); + } else { + // Handle '.' + abs_path = temp + path.substr(1); // remove '.' then append + } + } + } + } + + if (abs_path.empty()) { + abs_path = path; + } + + return (path = abs_path); + } + + /** + * @brief Ensure that the directory specified in the path exist + * @param filepath the file path containing an optional directory component + */ + void inline PVXS_API ensureDirectoryExists(std::string &filepath, bool convert_path = true) { + std::string temp_path = convert_path ? convertPath(filepath) : filepath; + + std::string delimiter = std::string(OSI_PATH_SEPARATOR); + size_t pos = 0; + std::string token; + std::string path = ""; + struct stat info{}; + while ((pos = temp_path.find(delimiter)) != std::string::npos) { + token = temp_path.substr(0, pos); + path += token + delimiter; + temp_path.erase(0, pos + delimiter.length()); + if (stat(path.c_str(), &info) != 0 || !(info.st_mode & S_IFDIR)) { + mkdir(path.c_str(), S_IRWXU); + } + } + } +#endif // EVENT2_HAS_OPENSSL + + //! TCP port to bind. Default is 5075. May be zero. + unsigned short tcp_port = 5075; + //! UDP port to bind. Default is 5076. May be zero, cf. Server::config() + //! to find allocated port. + unsigned short udp_port = 5076; + + //! Inactivity timeout interval for TCP connections. (seconds) + //! @since 0.2.0 + double tcpTimeout = 40.0; + +#ifdef PVXS_ENABLE_OPENSSL + //! TCP port to bind for TLS traffic. Default is 5076 + //! @since UNRELEASED + unsigned short tls_port = 5076; + + /** + * @brief If TLS is disabled this is set to true. This can happen + * if no certificate file is found and can't be configured and this is a + * server + */ + bool tls_disabled = false; + + /** Path to keychain file containing certificates and optional keys. + * @since UNRELEASED + */ + std::string tls_cert_filename; + + /** Path to file containing password for certificate file. + * @since UNRELEASED + */ + std::string tls_cert_password; + + /** Path to private key file containing key. + * @since UNRELEASED + */ + std::string tls_private_key_filename; + + /** Path to file containing password for the private key file. + * @since UNRELEASED + */ + std::string tls_private_key_password; + + /** Client certificate request during TLS handshake. + * + * - Default. Currently equivalent to Optional + * - Optional. Server will ask for a client cert. But will continue if none is provided. + * If a client cert. is provided, then it is validated. An invalid cert. + * will fail the handshake. + * - Require. Server will require a valid client cert. or the TLS handshake will fail. + * + * @since UNRELEASED + */ + enum CertificateRequiredness { + Default, + Optional, + Require, + } tls_client_cert_required = Default; + + /** + * @brief Behaviour of server and client if the certificate expires + * during the long running session. + * - FallbackToTCP. Only for clients, this will reinitialise the + * connection but in server-only authentication mode. + * - Shutdown. This will stop the process immediately + * - Standby. For servers, this will keep the server running but will reject all connections until the certificate has been renewed. + */ + enum OnExpirationBehaviour { + FallbackToTCP, + Shutdown, + Standby, + } expiration_behaviour = FallbackToTCP; + + /** + * @brief True if status checking from the PVACMS is disabled irrespective of whether configured in the certificate + */ + bool tls_disable_status_check{false}; + + /** + * @brief True if stapling is disabled irrespective of whether TLS is configured + */ + bool tls_disable_stapling{false}; + + /** + * @brief True if we want to throw an exception if we can't verify a cert with the + * PVACMS, otherwise we downgrade to a tcp connection + */ + bool tls_throw_if_cant_verify{false}; + + /** + * @brief The request timeout specified in a user call + * @note Cannot be set by an environment variable, but is passed in by commandline tools, or set programmatically + */ + double request_timeout_specified{5.0}; + + /** + * True if the environment is configured for TLS. All this means is that + * the location of the certificate file has been specified in + * EPICS_PVA_TLS_KEYCHAIN, and EPICS_PVA_TLS_PKEY. + * + * @return true if the location of the certificate file has been specified, + * false otherwise + */ + inline bool isTlsConfigured() const { return !tls_disabled && !tls_cert_filename.empty(); } +#endif // PVXS_ENABLE_OPENSSL + + inline std::string getFileContents(const std::string &file_name) { + std::ifstream ifs(file_name); + std::string contents((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); + + if (!contents.empty() && contents.back() == '\n') { + contents.pop_back(); + } + + return contents; + } + + struct PickOne { + const std::map &defs; + bool useenv; + + std::string name, val; + + bool operator()(std::initializer_list names) { + for (auto candidate : names) { + if (useenv) { + if (auto eval = getenv(candidate)) { + name = candidate; + val = eval; + return true; + } + + } else { + auto it = defs.find(candidate); + if (it != defs.end()) { + name = candidate; + val = it->second; + return true; + } + } + } + return false; + } + }; +}; +} // namespace impl +} // namespace pvxs + +#endif // PVXS_CONFIG_H diff --git a/src/pvxs/log.h b/src/pvxs/log.h index 8e967a813..4deb948d4 100644 --- a/src/pvxs/log.h +++ b/src/pvxs/log.h @@ -91,6 +91,9 @@ void xerrlogHexPrintf(const void *buf, size_t buflen); ::pvxs::detail:: _log_printf(unsigned(LVL), "%s " FMT, _log_prefix, __VA_ARGS__); \ }while(0) +#define log_println(LOGGER, LVL, FMT, ...) do{ \ +}while(0) + /* A note about MSVC (legacy) pre-processor weirdness. * Care needs to be taken when expanding nested macros w/ __VA_ARGS__. * Expansion of __VA_ARGS__ for the outer macro seems to result in @@ -116,6 +119,15 @@ void xerrlogHexPrintf(const void *buf, size_t buflen); #define log_info_printf(LOGGER, FMT, ...) log_printf(LOGGER, ::pvxs::Level::Info, FMT, __VA_ARGS__) #define log_debug_printf(LOGGER, FMT, ...) log_printf(LOGGER, ::pvxs::Level::Debug, FMT, __VA_ARGS__) +// Versions of log_..._printf that don't truncate but instead spread message over multiple log messages +// USE WITH CARE!!! - inserts a 0.0001 second pause every 10 lines - Recommend for DEBUG ONLY +#define log_exc_println(LOGGER, FMT, ...) log_println(LOGGER, unsigned(::pvxs::Level::Crit)|0x1000, FMT, __VA_ARGS__) +#define log_crit_println(LOGGER, FMT, ...) log_println(LOGGER, ::pvxs::Level::Crit, FMT, __VA_ARGS__) +#define log_err_println(LOGGER, FMT, ...) log_println(LOGGER, ::pvxs::Level::Err, FMT, __VA_ARGS__) +#define log_warn_println(LOGGER, FMT, ...) log_println(LOGGER, ::pvxs::Level::Warn, FMT, __VA_ARGS__) +#define log_info_println(LOGGER, FMT, ...) log_println(LOGGER, ::pvxs::Level::Info, FMT, __VA_ARGS__) +#define log_debug_println(LOGGER, FMT, ...) log_println(LOGGER, ::pvxs::Level::Debug, FMT, __VA_ARGS__) + #define log_hex_printf(LOGGER, LVL, BUF, BUFLEN, FMT, ...) do{ \ if(auto _log_prefix = ::pvxs::detail::log_prep(LOGGER, unsigned(LVL))) \ ::pvxs::detail:: _log_printf_hex(unsigned(LVL), BUF, BUFLEN, "%s " FMT, _log_prefix, __VA_ARGS__);\ @@ -131,15 +143,16 @@ inline void logger_level_set(const char *name, Level lvl) { //! Use prior to re-applying new configuration. PVXS_API void logger_level_clear(); -/** Configure logging from environment variable **$PVXS_LOG** +/** + * @brief Configure logging from environment variable `$PVXS_LOG` * - * Value of the form "key=VAL,..." + * Value of environment variable has the form `key=VAL,...` * - * Keys may be literal logger names, or may include '*' wildcards - * to match multiple loggers. eg. "pvxs.*=DEBUG" will enable + * - `keys`: Keys may be literal logger names, or may include `*` wildcards + * to match multiple loggers. eg. `pvxs.*=DEBUG` will enable * all internal log messages. * - * VAL may be one of "CRIT", "ERR", "WARN", "INFO", or "DEBUG" + * - `values`: `VAL` may be one of `CRIT`, `ERR`, `WARN`, `INFO`, or `DEBUG` */ PVXS_API void logger_config_env(); diff --git a/src/pvxs/netcommon.h b/src/pvxs/netcommon.h index d9e86b26d..58f7b6523 100644 --- a/src/pvxs/netcommon.h +++ b/src/pvxs/netcommon.h @@ -6,17 +6,74 @@ #ifndef PVXS_NETCOMMON_H #define PVXS_NETCOMMON_H -#if !defined(PVXS_CLIENT_H) && !defined(PVXS_SERVER_H) -# error Include or Do not include netcommon.h directly +#if !defined(PVXS_CLIENT_H) && !defined(PVXS_SERVER_H) && !defined(PVXS_SRVCOMMON_H) +# error Do not include netcommon.h directly #endif -#include #include #include +#include +#include +#include +#include #include +#ifdef PVXS_ENABLE_OPENSSL +using CertEventCallback = std::function; +static constexpr timeval statusIntervalInitial{0, 0}; +static constexpr timeval statusIntervalShort{15, 0}; +#endif + namespace pvxs { + +/** Credentials presented by a client or server. + * + * Primarily a way of presenting peer address and a remote account name. + * The ``method`` gives the authentication sub-protocol used and is presently one of: + * + * - "x509" - Peer certificate. Common Names of root CA and peer used as authority and account. + * - "ca" - Client provided account name. + * - "anonymous" - Client provided no credentials. account will also be "anonymous". + * + * @since UNRELEASED + */ +struct PVXS_API PeerCredentials { + //! Peer address (eg. numeric IPv4) + std::string peer; + //! The local interface address (eg. numeric IPv4) through which this client is connected. + //! May be a wildcard address (eg. 0.0.0.0) if the receiving socket is so bound. + std::string iface; + //! How account was authenticated. ("anonymous", "ca", or "x509") + std::string method; + //! Who vouches for this account. + //! + //! Empty for "anonymous" and "ca" methods. + //! For "x509" method, common name of the root CA. + //! @since UNRELEASED + std::string authority; + //! Remote user account name. Meaning depends upon method. + std::string account; + /** Lookup (locally) roles associated with the account. + * + * On *nix targets this is the list of primary and secondary groups + * in which the account is a member. + * On Windows targets this returns the list of local groups for the account. + * On other targets, an empty list is returned. + */ + std::set roles() const; + +#ifdef PVXS_ENABLE_OPENSSL + /** Operation over secure transport + * @since UNRELEASED + */ + bool isTLS = false; +#endif +}; + +PVXS_API +std::ostream& operator<<(std::ostream&, const PeerCredentials&); + namespace impl { struct Report; struct ReportInfo; @@ -74,7 +131,7 @@ struct PVXS_API ReportInfo { virtual ~ReportInfo(); }; -#endif +#endif // PVXS_EXPERT_API_ENABLED } // namespace impl } // namespace pvxs diff --git a/src/pvxs/server.h b/src/pvxs/server.h index 03fe66579..7ca5a336f 100644 --- a/src/pvxs/server.h +++ b/src/pvxs/server.h @@ -6,29 +6,43 @@ #ifndef PVXS_SERVER_H #define PVXS_SERVER_H -#include - -#include +#include #include +#include +#include +#include +#include #include #include -#include -#include #include -#include -#include #include +#include -#include -#include #include +#include #include +#include +#include + +#include "evhelper.h" + +#ifdef PVXS_ENABLE_OPENSSL +#include "openssl.h" +#endif namespace pvxs { namespace client { +struct Subscription; struct Config; } + +#ifdef PVXS_ENABLE_OPENSSL +namespace ossl { +struct SSLContext; +} +#endif + namespace server { struct SharedPV; @@ -59,6 +73,10 @@ class PVXS_API Server constexpr Server() = default; //! Create/allocate, but do not start, a new server with the provided config. explicit Server(const Config&); + +#ifdef PVXS_ENABLE_OPENSSL + Server(const Config &config, CertEventCallback cert_file_event_callback); +#endif Server(const Server&) = default; Server(Server&& o) = default; Server& operator=(const Server&) = default; @@ -71,7 +89,12 @@ class PVXS_API Server * @since 0.2.1 */ static +#ifndef PVXS_ENABLE_OPENSSL Server fromEnv(); +#else + Server fromEnv(bool tls_disabled = false, impl::ConfigCommon::ConfigTarget target = impl::ConfigCommon::SERVER); + Server fromEnv(CertEventCallback &cert_file_event_callback, bool tls_disabled = false, impl::ConfigCommon::ConfigTarget target = impl::ConfigCommon::SERVER); +#endif // PVXS_ENABLE_OPENSSL //! Begin serving. Does not block. Server& start(); @@ -91,7 +114,19 @@ class PVXS_API Server //! Queue a request to break run() Server& interrupt(); +#ifdef PVXS_ENABLE_OPENSSL + /** Apply (in part) updated configuration + * + * Currently, only updates TLS configuration. Causes all in-progress + * Operations to be disconnected. + * + * @since UNRELEASED + */ + void reconfigure(const Config&); +#endif + //! effective config + //! @since UNRELEASED Reference invalidated by a call to reconfigure() const Config& config() const; //! Create a client configuration which can communicate with this Server. @@ -100,6 +135,7 @@ class PVXS_API Server //! Add a SharedPV to the "__builtin" StaticSource Server& addPV(const std::string& name, const SharedPV& pv); + Server& addPV(const std::string& name, const SharedWildcardPV& pv); //! Remove a SharedPV from the "__builtin" StaticSource Server& removePV(const std::string& name); @@ -150,7 +186,7 @@ PVXS_API std::ostream& operator<<(std::ostream& strm, const Server& serv); //! Configuration for a Server -struct PVXS_API Config { +struct PVXS_API Config : public impl::ConfigCommon { //! List of network interface addresses (**not** host names) to which this server will bind. //! interfaces.empty() treated as an alias for "0.0.0.0", which may also be given explicitly. //! Port numbers are optional and unused (parsed and ignored) @@ -164,16 +200,23 @@ struct PVXS_API Config { //! May include broadcast and/or unicast addresses. //! Supplemented only if auto_beacon==true std::vector beaconDestinations; - //! TCP port to bind. Default is 5075. May be zero. - unsigned short tcp_port = 5075; - //! UDP port to bind. Default is 5076. May be zero, cf. Server::config() to find allocated port. - unsigned short udp_port = 5076; //! Whether to populate the beacon address list automatically. (recommended) bool auto_beacon = true; - //! Inactivity timeout interval for TCP connections. (seconds) - //! @since 0.2.0 - double tcpTimeout = 40.0; +#ifdef PVXS_ENABLE_OPENSSL + /** + * @brief true if server should stop if no cert is available or can be + * verified if status check is enabled + */ + bool tls_stop_if_no_cert = false; + + /** + * @brief true if server should throw an exception if no cert is available or can be + * verified if status check is enabled + */ + bool tls_throw_if_no_cert = false; + +#endif // PVXS_ENABLE_OPENSSL //! Server unique ID. Only meaningful in readback via Server::config() ServerGUID guid{}; @@ -183,20 +226,27 @@ struct PVXS_API Config { bool UDP = true; public: +#ifndef PVXS_ENABLE_OPENSSL // compat static inline Config from_env() { return Config{}.applyEnv(); } - //! Default configuration using process environment static inline Config fromEnv() { return Config{}.applyEnv(); } + //! update using defined EPICS_PVA* environment variables + Config& applyEnv(); +#else + static inline Config from_env(const bool tls_disabled = false, const ConfigTarget target = SERVER) { + return Config{}.applyEnv(tls_disabled, target); + } + static inline Config fromEnv(const bool tls_disabled = false, const ConfigTarget target = SERVER) { return Config{}.applyEnv(tls_disabled, target); } + Config &applyEnv(const bool tls_disabled = false, const ConfigTarget target = SERVER); +// Config &applyEnv(const bool tls_disabled = false); +#endif //! Configuration limited to the local loopback interface on a randomly chosen port. //! Suitable for use in self-contained unit-tests. //! @since 0.3.0 Address family argument added. static Config isolated(int family=AF_INET); - //! update using defined EPICS_PVA* environment variables - Config& applyEnv(); - typedef std::map defs_t; //! update with definitions as with EPICS_PVA* environment variables. //! Process environment is not changed. @@ -221,13 +271,25 @@ struct PVXS_API Config { return Server(*this); } + //! Create a new Server using the current configuration with a custom file event callback + inline Server build(CertEventCallback &cert_file_event_callback) const { + return Server(*this, cert_file_event_callback); + } + #ifdef PVXS_EXPERT_API_ENABLED // for protocol compatibility testing - inline Config& overrideSendBE(bool be) { BE = be; return *this; } + inline Config& overrideSendBE(bool be) { + BE = be; + return *this; + } inline bool sendBE() const { return BE; } - inline Config& overrideShareUDP(bool share) { UDP = share; return *this; } + inline Config& overrideShareUDP(bool share) { + UDP = share; + return *this; + } inline bool shareUDP() const { return UDP; } #endif + void fromDefs(Config& self, const std::map& defs, bool useenv); }; PVXS_API diff --git a/src/pvxs/sharedpv.h b/src/pvxs/sharedpv.h index 95e69a7bd..3eeaf5c55 100644 --- a/src/pvxs/sharedpv.h +++ b/src/pvxs/sharedpv.h @@ -8,8 +8,10 @@ #define PVXS_SHAREDPV_H #include +#include #include #include +#include #include #include "srvcommon.h" @@ -35,14 +37,14 @@ struct Source; * The onPut() and onRPC() methods attach functors which will be called each time a Put or RPC * operation is executed by a client. */ -struct PVXS_API SharedPV -{ +struct PVXS_API SharedPV { //! Create a new SharedPV with a Put handler which post() s any client provided Value. static SharedPV buildMailbox(); + //! Create a new SharedPV with a Put handler which rejects any client provided Value. static SharedPV buildReadonly(); - ~SharedPV(); + virtual ~SharedPV(); inline explicit operator bool() const { return !!impl; } @@ -79,10 +81,12 @@ struct PVXS_API SharedPV Value fetch() const; struct Impl; -private: + private: std::shared_ptr impl; }; +struct SharedWildcardPV; + /** Allow clients to find (through a Server) SharedPV instances by name. * * A single PV name may only be added once to a StaticSource. @@ -104,9 +108,12 @@ struct PVXS_API StaticSource //! Add a new name through which a SharedPV may be addressed. StaticSource& add(const std::string& name, const SharedPV& pv); + StaticSource& add(const std::string& name, const SharedWildcardPV& pv); + //! Remove a single name StaticSource& remove(const std::string& name); + typedef std::map> pv_list_t; typedef std::map list_t; list_t list() const; diff --git a/src/pvxs/sharedwildcardpv.h b/src/pvxs/sharedwildcardpv.h new file mode 100644 index 000000000..e458a3da0 --- /dev/null +++ b/src/pvxs/sharedwildcardpv.h @@ -0,0 +1,176 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifndef PVXS_SHAREDWILDCARDPV_H +#define PVXS_SHAREDWILDCARDPV_H + +#include +#include +#include +#include +#include + +#include +#include +#include "srvcommon.h" + +namespace pvxs { +class Value; + +namespace server { + +struct ChannelControl; +struct Source; + +/** A SharedWildcardPV is multiple data values which may be accessed by multiple clients through a Server. + * + * On creation a SharedWildcardPV has no associated data structure, or data type. + * This is set by calling open(pvname) to provide a data type, and initial data values. + * Subsequent calls to post(pvname) will update this data structure (and send notifications + * to subscribing clients). + * + * A later call to close(pvname) will force disconnect all clients, and discard the data value and type. + * A further call to open(pvname) sets a new data value, which may be of a different data type. + * + * The onPut(pvname) and onRPC(pvname) methods attach functors which will be called each time a Put or RPC + * operation is executed by a client. + * + * Servers provide implementations for onFirstConnect() and onFirstDisconnect() as well as onRPC() which + * provide `pvname` and `parameters` as arguments to be used for processing and to call + * the open(pvname), close(pvname), onRPC(pvname), post(pvname) lower level functions. + * + * Serves can optionally provide the onPut() handler again with pvname and parameters arguments + * and can call low level post(pvname) + * + * monitoring and get are handled automatically by the framework + */ +struct PVXS_API SharedWildcardPV : public SharedPV { + //! Create a new SharedPV with a Put handler which post() s any client provided Value. + static SharedWildcardPV buildMailbox(); + + //! Create a new SharedWildcardPV with a Put handler which rejects any client provided Value. + static SharedWildcardPV buildReadonly(); + + ~SharedWildcardPV(); + + //! Attach this SharedPV with a new client channel. + //! Not necessary when using StaticSource. + //! eg. could call from Source::onCreate() + void attach(std::unique_ptr&& op, const std::list parameters); + + //! Callback when the number of attach()d clients becomes non-zero for a particular pv_name + void onFirstConnect(std::function &)>&& fn); + //! Callback when the number of attach()d clients becomes zero for a particular pv_name + void onLastDisconnect(std::function &)>&& fn); + //! Callback when a client executes a new Put operation for a given pv_name + void onPut(std::function&&, const std::string &, const std::list &, Value&&)>&& fn); + //! Callback when a client executes an RPC operation for a given pc_name + //! @note RPC operations are allowed even when the SharedPV is not opened (isOpen()==false) + void onRPC(std::function&&, const std::string &, const std::list &, Value&&)>&& fn); + + /** Provide data type and initial value. Allows clients to begin connecting. + * @pre !isOpen() + * @param pv_name For wildcard PVs this indicates the actual PV requested + * @param initial Defines data type, and initial value + */ + void open(const std::string &pv_name, const Value& initial); + /** + * @brief Test whether open(pv_name) has been called w/o matching close(pv_name) + * @param pv_name For wildcard PVs this indicates the actual PV requested + * @return true if open + */ + bool isOpen(const std::string &pv_name) const; + /** + * @brief Reverse the effects of open(pv_name) and force disconnect any remaining clients. + * @param pv_name For wildcard PVs this indicates the actual PV requested + */ + void close(const std::string &pv_name); + /** + * @brief Update the internal data value, and dispatch subscription updates to any clients. + * @param pv_name For wildcard PVs this indicates the actual PV requested + * @param val the value to post + */ + void post(const std::string &pv_name, const Value& val); + /** + * @brief query the internal data value and update the provided Value. + * @param pv_name For wildcard PVs this indicates the actual PV requested + * @param val reference to value to update by fetching + */ + void fetch(const std::string &pv_name, Value& val) const; + /** + * @brief Return a (shallow) copy of the internal data value + * @param pv_name For wildcard PVs this indicates the actual PV requested + * @return shallow copy of the internal data value + */ + Value fetch(const std::string &pv_name) const; + + struct Impl; + /** + * @brief The Wildcard PV name, only set when wildcard match is called + */ + std::string wildcard_pv; + /** + * @brief Get the parameters from the given wildcard PV name. + * Will provide strings for each of the parts of the PV name matched by + * patterns in the wildcard PV name. e.g. strings of `???` or `*` will + * resolve to strings in the returned vector. + * + * @param pv_name For wildcard PVs this indicates the actual PV requested + * @return a list of strings that correspond to the parts of the PV name matched by pattersn in the Wildcard PV. + */ + inline const std::list getParameters(const std::string& pv_name) noexcept { + std::list parameters; + size_t pv_name_pos = 0; + size_t wildcard_pv_pos = 0; + + while (wildcard_pv_pos < wildcard_pv.length() && pv_name_pos < pv_name.length()) { + if (wildcard_pv[wildcard_pv_pos] == '?') { + // Extract the sequence of '?' matched characters + size_t start = pv_name_pos; + while (wildcard_pv_pos < wildcard_pv.length() && wildcard_pv[wildcard_pv_pos] == '?') { + wildcard_pv_pos++; + pv_name_pos++; + } + parameters.push_back(pv_name.substr(start, pv_name_pos - start)); + } else if (wildcard_pv[wildcard_pv_pos] == '*') { + // Extract the sequence of '*' matched characters + size_t start = pv_name_pos; + wildcard_pv_pos++; + if (wildcard_pv_pos < wildcard_pv.length()) { + // There are more characters in format after '*', find the next part + char next_char = wildcard_pv[wildcard_pv_pos]; + pv_name_pos = pv_name.find(next_char, pv_name_pos); + if (pv_name_pos != std::string::npos) { + parameters.push_back(pv_name.substr(start, pv_name_pos - start)); + } else { + // This condition should not happen in a valid input where the non '*' and '?' match correctly + parameters.push_back(pv_name.substr(start)); + return parameters; + } + } else { + // '*' is the last character in format, extract till the end of pv_name + parameters.push_back(pv_name.substr(start)); + return parameters; + } + } else { + // Skip the non '?' and '*' characters in the format + wildcard_pv_pos++; + pv_name_pos++; + } + } + return parameters; + } + private: + std::shared_ptr impl; + + template + bool exists(const std::map&m , const std::string &ref) const; +}; + +} // namespace server +} // namespace pvxs + +#endif // PVXS_SHAREDWILDCARDPV_H diff --git a/src/pvxs/srvcommon.h b/src/pvxs/srvcommon.h index 8ab9e4fdb..454775d4b 100644 --- a/src/pvxs/srvcommon.h +++ b/src/pvxs/srvcommon.h @@ -18,45 +18,23 @@ #include #include #include +#include namespace pvxs { namespace server { /** Credentials presented by a client. * - * Primarily a way of presenting peer address and a remote user account name. - * The method gives the authentication sub-protocol used and is presently one of: - * - * - "ca" - Client provided account name. - * - "anonymous" - Client provided no credentials. account will also be "anonymous". + * See pvxs::PeerCredentials * * @since 0.2.0 + * @since UNRELEASED Add PeerCredentials base class */ -struct PVXS_API ClientCredentials { - //! Peer address (eg. numeric IPv4) - std::string peer; - //! The local interface address (eg. numeric IPv4) through which this client is connected. - //! May be a wildcard address (eg. 0.0.0.0) if the receiving socket is so bound. - std::string iface; - //! Authentication "method" - std::string method; - //! Remote user account name. Meaning depends upon method. - std::string account; +struct ClientCredentials : public PeerCredentials { //! (Copy of) Credentials blob as presented by the client. Value raw; - /** Lookup (locally) roles associated with the account. - * - * On *nix targets this is the list of primary and secondary groups - * in with the account is a member. - * On Windows targets this returns the list of local groups for the account. - * On other targets, an empty list is returned. - */ - std::set roles() const; }; -PVXS_API -std::ostream& operator<<(std::ostream&, const ClientCredentials&); - //! Base for all operation classes struct PVXS_API OpBase { enum op_t { @@ -125,9 +103,7 @@ struct PVXS_API ExecOp : public OpBase { #ifdef PVXS_EXPERT_API_ENABLED //! Create/start timer. cb runs on worker associated with Channel of this Operation. //! @since 0.2.0 - Timer timerOneShot(double delay, std::function&& cb) { - return _timerOneShot(delay, std::move(cb)); - } + Timer timerOneShot(double delay, std::function&& cb) { return _timerOneShot(delay, std::move(cb)); } #endif // PVXS_EXPERT_API_ENABLED private: virtual Timer _timerOneShot(double delay, std::function&& cb) =0; diff --git a/src/server.cpp b/src/server.cpp index 0a45dc92c..043ab4eb4 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -4,51 +4,81 @@ * in file LICENSE that is included with this distribution. */ - +#include +#include +#include #include #include #include -#include -#include -#include - -#include #include #include -#include -#include #include #include +#include +#include +#include -#include #include +#include #include +#include +#include +#include + +#include "certstatusmanager.h" #include "evhelper.h" +#include "p12filewatcher.h" #include "serverconn.h" -#include "utilpvt.h" #include "udp_collector.h" +#include "utilpvt.h" namespace pvxs { namespace impl { ReportInfo::~ReportInfo() {} -} +} // namespace impl namespace server { using namespace impl; -DEFINE_LOGGER(serversetup, "pvxs.server.setup"); -DEFINE_LOGGER(serverio, "pvxs.server.io"); -DEFINE_LOGGER(serversearch, "pvxs.server.search"); +DEFINE_LOGGER(serversetup, "pvxs.svr.init"); +DEFINE_LOGGER(osslsetup, "pvxs.ossl.init"); +DEFINE_LOGGER(watcher, "pvxs.certs.mon"); +DEFINE_LOGGER(filemon, "pvxs.file.mon"); +DEFINE_LOGGER(serverio, "pvxs.svr.io"); +DEFINE_LOGGER(serversearch, "pvxs.svr.search"); // mimic pvAccessCPP server (almost) // send a "burst" of beacons, then fallback to a longer interval static constexpr timeval beaconIntervalShort{15, 0}; static constexpr timeval beaconIntervalLong{180, 0}; +#ifndef PVXS_ENABLE_OPENSSL Server Server::fromEnv() { return Config::fromEnv().build(); } +#else +Server Server::fromEnv(const bool tls_disabled, const ConfigCommon::ConfigTarget target) +{ + return Config::fromEnv(tls_disabled, target).build(); +} + +Server Server::fromEnv(CertEventCallback &cert_file_event_callback, const bool tls_disabled, const ConfigCommon::ConfigTarget target) +{ + return Config::fromEnv(tls_disabled, target).build(cert_file_event_callback); +} + +Server::Server(const Config &conf, CertEventCallback cert_file_event_callback) { + auto internal(std::make_shared(conf, cert_file_event_callback)); + internal->internal_self = internal; + + // external + pvt.reset(internal.get(), [internal](Pvt*) mutable { + auto trash(std::move(internal)); + trash->stop(); + }); +} +#endif Server::Server(const Config& conf) { @@ -71,6 +101,7 @@ Server::Server(const Config& conf) auto trash(std::move(internal)); trash->stop(); }); + // we don't keep a weak_ptr to the external reference. // Caller is entirely responsible for keeping this server running } @@ -162,12 +193,21 @@ client::Config Server::clientConfig() const throw std::logic_error("NULL Server"); client::Config ret; + // do not copy tls_cert_file ret.udp_port = pvt->effective.udp_port; ret.tcp_port = pvt->effective.tcp_port; ret.interfaces = pvt->effective.interfaces; ret.addressList = pvt->effective.interfaces; ret.autoAddrList = false; +#ifdef PVXS_ENABLE_OPENSSL + ret.tls_port = pvt->effective.tls_port; + ret.tls_disabled = pvt->effective.tls_disabled; + ret.tls_disable_status_check = pvt->effective.tls_disable_status_check; + ret.tls_disable_stapling = pvt->effective.tls_disable_stapling; +#endif + ret.is_initialized = true; + return ret; } @@ -180,6 +220,15 @@ Server& Server::addPV(const std::string& name, const SharedPV& pv) return *this; } +Server& Server::addPV(const std::string& name, const SharedWildcardPV& pv) +{ + if(!pvt) + throw std::logic_error("NULL Server"); + pvt->builtinsrc.add(name, pv); + pvt->beaconChange++; + return *this; +} + Server& Server::removePV(const std::string& name) { if(!pvt) @@ -323,6 +372,18 @@ std::ostream& operator<<(std::ostream& strm, const Server& serv) } strm<<"\n"; +#ifdef PVXS_ENABLE_OPENSSL + if (serv.pvt->tls_context) { + auto cert(serv.pvt->tls_context.certificate0()); + assert(cert); + strm << indent{} << "TLS Cert. " << ossl::ShowX509{cert} << "\n"; + } else { + strm << indent{} << "TLS Cert. not loaded\n"; + } +#else + strm<connections) { @@ -331,15 +392,26 @@ std::ostream& operator<<(std::ostream& strm, const Server& serv) strm<peerName <<" backlog="<backlog.size() <<" TX="<statTx<<" RX="<statRx - <<" auth="<cred->method<<"\n"; - if(detail>2) - strm<<*conn->cred; + <<" auth="<cred->method +#ifdef PVXS_ENABLE_OPENSSL + <<(conn->iface->isTLS ? " TLS" : "") +#endif + <<"\n"; if(detail<=2) continue; Indented I(strm); + strm<cred<<"\n"; +#ifdef PVXS_ENABLE_OPENSSL + if (conn->iface->isTLS && conn->connection()) { + auto ctx = bufferevent_openssl_get_ssl(conn->connection()); + assert(ctx); + if (auto cert = SSL_get0_peer_certificate(ctx)) strm << indent{} << "Cert: " << ossl::ShowX509{cert} << "\n"; + } +#endif + for(auto& pair : conn->chanBySID) { auto& chan = pair.second; strm<name<<" TX="<statTx<<" RX="<statRx<<' '; @@ -378,20 +450,103 @@ std::ostream& operator<<(std::ostream& strm, const Server& serv) return strm; } -Server::Pvt::Pvt(const Config &conf) - :effective(conf) - ,beaconMsg(128) - ,acceptor_loop("PVXTCP", epicsThreadPriorityCAServerLow-2) - ,beaconSender4(AF_INET, SOCK_DGRAM, 0) - ,beaconSender6(AF_INET6, SOCK_DGRAM, 0) - ,beaconTimer(__FILE__, __LINE__, - event_new(acceptor_loop.base, -1, EV_TIMEOUT, doBeaconsS, this)) - ,searchReply(0x10000) - ,builtinsrc(StaticSource::build()) - ,state(Stopped) +#ifndef PVXS_ENABLE_OPENSSL +Server::Pvt::Pvt(const Config& conf) +#else +Server::Pvt::Pvt(const Config& conf, CertEventCallback custom_cert_event_callback) +#endif + : effective(conf), + beaconMsg(128), + acceptor_loop("PVXTCP", epicsThreadPriorityCAServerLow - 2), + beaconSender4(AF_INET, SOCK_DGRAM, 0), + beaconSender6(AF_INET6, SOCK_DGRAM, 0), + beaconTimer(__FILE__, __LINE__, event_new(acceptor_loop.base, -1, EV_TIMEOUT, doBeaconsS, this)), + searchReply(0x10000), + builtinsrc(StaticSource::build()), + state(Stopped) +#ifdef PVXS_ENABLE_OPENSSL + , + custom_cert_event_callback(custom_cert_event_callback), + cert_event_timer(__FILE__, __LINE__, event_new(acceptor_loop.base, -1, EV_TIMEOUT, doCertEventHandler, this)), + cert_validity_timer(__FILE__, __LINE__, event_new(acceptor_loop.base, -1, EV_TIMEOUT, doCertStatusValidityEventhandler, this)), + file_watcher(filemon, {effective.tls_cert_filename, effective.tls_cert_password, effective.tls_private_key_filename, effective.tls_private_key_password}, + [this](bool enable) { + if (enable) + acceptor_loop.dispatch([this]() mutable { enableTls(); }); + else + acceptor_loop.dispatch([this]() mutable { disableTls(); }); + }) +#endif { effective.expand(); +#ifdef PVXS_ENABLE_OPENSSL + if (effective.isTlsConfigured()) { + try { + log_debug_printf(osslsetup, "Begin TLS setup with cert file: %s\n", effective.tls_cert_filename.c_str()); + tls_context = ossl::SSLContext::for_server(effective); + if (tls_context.has_cert) { + if (auto cert_ptr = getCert()) { + if (tls_context.status_check_disabled) { + Guard G(tls_context.lock); + tls_context.cert_is_valid = true; + log_warn_printf(osslsetup, "Certificate status monitoring disabled by config: %s\n", effective.tls_cert_filename.c_str()); + } else { + auto cert = ossl_ptr(X509_dup(cert_ptr)); + // For servers, we need to wait for status if status monitoring is enabled + try { + // Subscribe to the server's certificate status and wait until at least first update is received + // TODO change to subscribe() only and then don't respond to SEARCH_REQUEST until status is available + cert_status_manager = certs::CertStatusManager::getAndSubscribe(acceptor_loop, std::move(cert), [this](certs::PVACertificateStatus status) { + Guard G(tls_context.lock); + auto was_good = current_status && current_status->isGood(); + current_status = std::make_shared(status); + if (current_status && current_status->isGood()) { + if (!was_good) acceptor_loop.dispatch([this]() mutable { enableTls(); }); + } else if (was_good) { + acceptor_loop.dispatch([this]() mutable { disableTls(); }); + } + }); + if (current_status && current_status->isGood()) { + Guard G(tls_context.lock); + tls_context.cert_is_valid = true; + log_info_printf(osslsetup, "TLS enabled for server with cert %s with reported %s status\n", effective.tls_cert_filename.c_str(), + current_status->status.s.c_str()); + } else { + if (effective.tls_stop_if_no_cert) { + log_err_printf(osslsetup, "***EXITING***: Unable to contact PVACMS to verify cert %s\n", effective.tls_cert_filename.c_str()); + exit(1); + } else if (effective.tls_throw_if_no_cert) { + log_err_printf(osslsetup, "Unable to contact PVACMS to verify cert %s\n", effective.tls_cert_filename.c_str()); + throw(std::runtime_error("Unable to contact PVACMS")); + } else { + log_info_printf(osslsetup, "TLS disabled for server with reported %s status for cert %s\n", current_status->status.s.c_str(), + effective.tls_cert_filename.c_str()); + } + } + } catch (certs::CertStatusNoExtensionException& e) { + Guard G(tls_context.lock); + tls_context.cert_is_valid = true; + log_info_printf(osslsetup, "TLS enabled for server without status check: %s\n", effective.tls_cert_filename.c_str()); + } catch (std::exception& e) { + throw(std::runtime_error(SB() << e.what() << ": Waiting for PVACMS to report status for cert " << effective.tls_cert_filename)); + } + } + } + } + } catch (std::exception& e) { + if (effective.tls_stop_if_no_cert) { + log_err_printf(osslsetup, "***EXITING***: TLS disabled for server: %s\n", e.what()); + exit(1); + } else if (effective.tls_throw_if_no_cert) { + throw(std::runtime_error(e.what())); + } else { + log_warn_printf(osslsetup, "TLS disabled for server: %s\n", e.what()); + } + } + } +#endif + beaconSender4.set_broadcast(true); auto manager = UDPManager::instance(effective.shareUDP()); @@ -492,23 +647,38 @@ Server::Pvt::Pvt(const Config &conf) acceptor_loop.call([this, &tcpifaces](){ // from accepter worker - bool firstiface = true; - for(auto& addr : tcpifaces) { - if(addr.port()==0) - addr.setPort(effective.tcp_port); +#ifdef PVXS_ENABLE_OPENSSL + decltype(tcpifaces) tlsifaces(tcpifaces); // copy before any setPort() +#endif + bool firstiface = true; + for (auto& addr : tcpifaces) { + if (addr.port() == 0) addr.setPort(effective.tcp_port); - interfaces.emplace_back(addr, this, firstiface); + interfaces.emplace_back(addr, this, firstiface, false); - if(firstiface || effective.tcp_port==0) - effective.tcp_port = interfaces.back().bind_addr.port(); - firstiface = false; - } + if (firstiface || effective.tcp_port == 0) effective.tcp_port = interfaces.back().bind_addr.port(); + firstiface = false; + } - for(const auto& addr : effective.beaconDestinations) { - beaconDest.emplace_back(addr.c_str(), effective.udp_port); - log_debug_printf(serversetup, "Will send beacons to %s\n", - std::string(SB()<start(); } @@ -611,12 +785,34 @@ void Server::Pvt::start() state = Running; }); + // begin monitoring status + acceptor_loop.call([this]() + { + // monitor first file status with initial delay + if(event_add(cert_event_timer.get(), &statusIntervalInitial)) + log_err_printf(serversetup, "Error enabling file monitor\n%s", ""); + + state = Running; + }); + } void Server::Pvt::stop() { log_debug_printf(serversetup, "Server Stopping\n%s", ""); +#ifdef PVXS_ENABLE_OPENSSL + // Stop status subscription if enabled + if (cert_status_manager) { + cert_status_manager->unsubscribe(); + cert_status_manager.reset(); + } + + // Stop file watcher if enabled + if (file_watcher.isRunning()) { + file_watcher.stop(); + } +#endif // Stop sending Beacons state_t prev_state; @@ -716,7 +912,7 @@ void Server::Pvt::onSearch(const UDPManager::Search& msg) } // "pvlist" breaks unless we honor mustReply flag - if(nreply==0 && !msg.mustReply) + if(nreply==0 && !msg.mustReply && (msg.protoTCP || msg.protoTLS)) return; VectorOutBuf M(true, searchReply); @@ -726,8 +922,16 @@ void Server::Pvt::onSearch(const UDPManager::Search& msg) _to_wire<12>(M, effective.guid.data(), false, __FILE__, __LINE__); to_wire(M, msg.searchID); to_wire(M, SockAddr::any(AF_INET)); - to_wire(M, uint16_t(effective.tcp_port)); - to_wire(M, "tcp"); +#ifdef PVXS_ENABLE_OPENSSL + if(msg.protoTLS && tls_context && effective.tls_port && tls_context.cert_is_valid) { + to_wire(M, uint16_t(effective.tls_port)); + to_wire(M, "tls"); + } else +#endif + { // protoTCP + to_wire(M, uint16_t(effective.tcp_port)); + to_wire(M, "tcp"); + } // "found" flag to_wire(M, uint8_t(nreply!=0 ? 1 : 0)); @@ -820,6 +1024,153 @@ void Server::Pvt::doBeaconsS(evutil_socket_t fd, short evt, void *raw) } } +#ifdef PVXS_ENABLE_OPENSSL +DO_CERT_EVENT_HANDLER(Server::Pvt, serverio, CUSTOM) +DO_CERT_STATUS_VALIDITY_EVENT_HANDLER(Server::Pvt, getPVAStatus) + +void Server::reconfigure(const Config& inconf) { + if (!pvt) throw std::logic_error("NULL Server"); + + auto newconf(inconf); + newconf.expand(); // maybe catch some errors early + + log_info_printf(serversetup, "Reconfiguring Server Context%s", "\n"); + + // is the current server running? + + Pvt::state_t prev_state; + pvt->acceptor_loop.call([this, &prev_state]() { prev_state = pvt->state; }); + + bool was_running = prev_state == Pvt::Running || prev_state == Pvt::Starting; + + if (was_running) pvt->stop(); + + decltype(pvt->sources) transfers; + decltype(pvt->builtinsrc) builtin; + + // copy all Source, including builtin + { + auto G(pvt->sourcesLock.lockReader()); + + transfers = pvt->sources; + builtin = pvt->builtinsrc; + } + + // completely destroy the current/old server to free up TCP ports + pvt.reset(); + + // build up a new, empty, server + Server newsrv(newconf); + pvt = std::move(newsrv.pvt); + + { + auto G(pvt->sourcesLock.lockWriter()); + + pvt->sources = transfers; + pvt->builtinsrc = builtin; + } + + if (was_running) { + pvt->start(); + log_info_printf(serversetup, "Resuming Server after Reconfiguration%s", "\n"); + } +} + +/** + * @brief Enable TLS with the optional config if provided + * @param new_config optional config (check the is_initialized flag to see if its blank or not) + */ +void Server::Pvt::enableTls(const Config& new_config) { + // If already valid then don't do anything + if (tls_context.has_cert && tls_context.cert_is_valid) return; + + log_debug_printf(watcher, "Enabling TLS. Certificate file is %s\n", effective.tls_cert_filename.c_str()); + try { + // Exclude PVACMS + if (effective.config_target != ConfigCommon::CMS) { + Guard G(tls_context.lock); // We can lock here because `for_server` will create a completely different tls_context + + // if cert isn't valid then get a new one + if (!tls_context.has_cert) { + log_debug_printf(watcher, "Creating a new Server TLS context from %s\n", effective.tls_cert_filename.c_str()); + auto new_context = ossl::SSLContext::for_server(new_config.is_initialized ? new_config : effective); + + // If unsuccessful in getting cert or cert is invalid then don't do anything + if (!new_context.has_cert || !new_context.cert_is_valid) { + log_debug_printf(watcher, "Failed to create new Server TLS context: TLS disabled: %s\n", effective.tls_cert_filename.c_str()); + return; + } + tls_context = new_context; + effective = (new_config.is_initialized ? new_config : effective); + } + + // Subscribe to certificate status if not already subscribed + if (!cert_status_manager && tls_context.status_check_disabled) { + log_debug_printf(watcher, "Subscribing to Server certificate status: %s\n", effective.tls_cert_filename.c_str()); + subscribeToCertStatus(); + } + + // All new connections will be able to be TLS + // Don't drop any existing connections + + // Set callback for when this status' validity ends + if (!tls_context.status_check_disabled) { + log_debug_printf(watcher, "Starting server certificate status validity timer after receiving a status update for %s\n", + effective.tls_cert_filename.c_str()); + startStatusValidityTimer(); + } + + tls_context.cert_is_valid = true; + log_info_printf(watcher, "TLS enabled for server due to a certificate status update on %s\n", effective.tls_cert_filename.c_str()); + } + } catch (std::exception& e) { + log_debug_printf(watcher, "%s: TLS remains disabled for server with %s\n", e.what(), effective.tls_cert_filename.c_str()); + } +} + +void Server::Pvt::disableTls() { + log_debug_printf(watcher, "Disabling TLS%s\n", ""); + Guard G(tls_context.lock); + if (cert_status_manager) { + // Stop subscribing to status + log_debug_printf(watcher, "Disable TLS: Stopping certificate monitor%s\n", ""); + cert_status_manager.reset(); + } + + // Skip if TLS is already disabled + if (!tls_context.has_cert || !tls_context.cert_is_valid) return; + + // Remove all tls connections so that clients will reconnect as tcp + std::vector> to_cleanup; + // Collect tls connections to clean-up + for (auto& pair : connections) { + auto conn = pair.first; + if (conn && conn->iface->isTLS) { + to_cleanup.push_back(pair.second); + } + } + + log_debug_printf(watcher, "Closing %zu TLS connections\n", to_cleanup.size()); + // Clean them up + for (auto& weak_conn : to_cleanup) { + auto conn = weak_conn.lock(); + if (conn) { + conn->cleanup(); + } + } + + tls_context.cert_is_valid = false; + tls_context.has_cert = false; + log_warn_printf(watcher, "TLS disabled for server%s\n", ""); +} + +FILE_EVENT_CALLBACK(Server::Pvt) +GET_CERT(Server::Pvt) +START_STATUS_VALIDITY_TIMER(Server::Pvt, acceptor_loop) +SUBSCRIBE_TO_CERT_STATUS(Server::Pvt, PVACertificateStatus, acceptor_loop) + +#endif + Source::~Source() {} Source::List Source::onList() { diff --git a/src/serverchan.cpp b/src/serverchan.cpp index 36a08656f..f7252138e 100644 --- a/src/serverchan.cpp +++ b/src/serverchan.cpp @@ -13,11 +13,11 @@ namespace pvxs {namespace impl { // message related to client state and errors -DEFINE_LOGGER(connsetup, "pvxs.tcp.setup"); +DEFINE_LOGGER(connsetup, "pvxs.tcp.init"); // related to low level send/recv DEFINE_LOGGER(connio, "pvxs.tcp.io"); -DEFINE_LOGGER(serversearch, "pvxs.server.search"); +DEFINE_LOGGER(serversearch, "pvxs.svr.search"); ServerChan::ServerChan(const std::shared_ptr &conn, uint32_t sid, @@ -183,12 +183,20 @@ void ServerConn::handle_SEARCH() M.skip(3 + 16 + 2, __FILE__, __LINE__); // unused and replyAddr (we always and only reply to TCP peer) bool foundtcp = false; +#ifdef PVXS_ENABLE_OPENSSL + bool foundtls = false; +#endif Size nproto{0}; from_wire(M, nproto); for(size_t i=0; iserver->tls_context && iface->server->effective.tls_port) + foundtls = true; +#endif } uint16_t nchan=0; @@ -227,7 +235,10 @@ void ServerConn::handle_SEARCH() nreply++; } - if(nreply==0 && !mustReply) + if(nreply==0 && !mustReply && !foundtcp ) +#ifdef PVXS_ENABLE_OPENSSL + if (!foundtls) +#endif return; { @@ -238,8 +249,17 @@ void ServerConn::handle_SEARCH() _to_wire<12>(R, iface->server->effective.guid.data(), false, __FILE__, __LINE__); to_wire(R, searchID); to_wire(R, SockAddr::any(AF_INET)); - to_wire(R, iface->bind_addr.port()); - to_wire(R, "tcp"); +#ifdef PVXS_ENABLE_OPENSSL + if(foundtls) { + to_wire(R, iface->server->effective.tls_port); + to_wire(R, "tls"); // prefer TLS + + } else +#endif + if(foundtcp) { + to_wire(R, iface->server->effective.tcp_port); + to_wire(R, "tcp"); + } // "found" flag to_wire(R, uint8_t(nreply!=0 ? 1 : 0)); @@ -297,6 +317,7 @@ void ServerConn::handle_CREATE_CHANNEL() for(auto& pair : iface->server->sources) { try { + auto source = pair.second; pair.second->onCreate(std::move(op)); const char* msg = nullptr; diff --git a/src/serverconn.cpp b/src/serverconn.cpp index fe5f65a02..a3ef57d50 100644 --- a/src/serverconn.cpp +++ b/src/serverconn.cpp @@ -13,6 +13,7 @@ #include #include +#include "openssl.h" #include "serverconn.h" // limit on size of TX buffer above which we suspend RX. @@ -26,42 +27,53 @@ DEFINE_INST_COUNTER(ServerChan); DEFINE_INST_COUNTER(ServerConn); DEFINE_INST_COUNTER(ServerSource); } -namespace server { - -DEFINE_INST_COUNTER2(Server::Pvt, ServerPvt); -std::set ClientCredentials::roles() const +std::set PeerCredentials::roles() const { std::set ret; osdGetRoles(account, ret); return ret; } -std::ostream& operator<<(std::ostream& strm, const ClientCredentials& cred) +std::ostream& operator<<(std::ostream& strm, const PeerCredentials& cred) { - strm<server->effective.sendBE(), - bufferevent_socket_new(iface->server->acceptor_loop.base, sock, BEV_OPT_CLOSE_ON_FREE|BEV_OPT_DEFER_CALLBACKS), + evbufferevent(__FILE__, __LINE__, + bufferevent_socket_new(iface->server->acceptor_loop.base, sock, BEV_OPT_CLOSE_ON_FREE|BEV_OPT_DEFER_CALLBACKS) + ), SockAddr(peer)) ,iface(iface) ,tcp_tx_limit(evsocket::get_buffer_size(sock, true) * tcp_tx_limit_mult) { - log_debug_printf(connio, "Client %s connects, RX readahead %zu TX limit %zu\n", - peerName.c_str(), readahead, tcp_tx_limit); + log_debug_printf(connio, "Client %s connects%s, RX readahead %zu TX limit %zu\n", peerName.c_str(), +#ifdef PVXS_ENABLE_OPENSSL + iface->isTLS ? " TLS" : +#endif + "", readahead, tcp_tx_limit); { int opt = 1; if(setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (char*)&opt, sizeof(opt))<0) { @@ -70,6 +82,36 @@ ServerConn::ServerConn(ServIface* iface, evutil_socket_t sock, struct sockaddr * } } +#ifdef PVXS_ENABLE_OPENSSL + if (iface->isTLS) { + assert(iface->server->tls_context); + auto ssl(SSL_new(iface->server->tls_context.ctx)); + if (!ssl) throw ossl::SSLError("SSL_new()"); + + if (!iface->server->tls_context.stapling_disabled && !iface->server->tls_context.status_check_disabled) { + try { + log_debug_printf(stapling, "Server OCSP Stapling: installing callback%s\n", ""); + ossl::stapleOcspResponse((void*)iface->server, ssl); // Staple response + } catch (certs::OCSPParseException& e) { + log_debug_printf(stapling, "Server OCSP Stapling: failed to install callback: %s\n", e.what()); + } catch (std::exception& e) { + log_debug_printf(stapling, "Server OCSP Stapling: failed to install callback: %s\n", e.what()); + } + } + + auto rawconn = bev.release(); + // BEV_OPT_CLOSE_ON_FREE will free on error + evbufferevent tlsconn(__FILE__, __LINE__, + bufferevent_openssl_filter_new(iface->server->acceptor_loop.base, rawconn, ssl, BUFFEREVENT_SSL_ACCEPTING, + BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS)); + bev = std::move(tlsconn); + + // added with libevent 2.2.1-alpha + //(void)bufferevent_ssl_set_flags(bev.get(), BUFFEREVENT_SSL_DIRTY_SHUTDOWN); + // deprecated, but not yet removed + bufferevent_openssl_set_allow_dirty_shutdown(bev.get(), 1); + } +#endif { auto cred(std::make_shared()); cred->peer = peerName; @@ -79,6 +121,7 @@ ServerConn::ServerConn(ServIface* iface, evutil_socket_t sock, struct sockaddr * this->cred = std::move(cred); } + // TODO Sends the event to handle the, sets timeout, and bufferevent_setcb(bev.get(), &bevReadS, &bevWriteS, &bevEventS, this); timeval tmo(totv(iface->server->effective.tcpTimeout)); @@ -106,9 +149,17 @@ ServerConn::ServerConn(ServIface* iface, evutil_socket_t sock, struct sockaddr * * Old pvAccess* was missing a "break" when looping, * so it took the last known plugin. */ - to_wire(M, Size{2}); + to_wire(M, Size{ +#ifdef PVXS_ENABLE_OPENSSL + iface->isTLS ? 3u : +#endif + 2u}); to_wire(M, "anonymous"); to_wire(M, "ca"); +#ifdef PVXS_ENABLE_OPENSSL + if(iface->isTLS) + to_wire(M, "x509"); +#endif auto bend = M.save(); FixedBuf H(sendBE, save, 8); @@ -199,6 +250,9 @@ void ServerConn::handle_CONNECTION_VALIDATION() std::string(SB()<(*cred)); +#ifdef PVXS_ENABLE_OPENSSL + C->isTLS = iface->isTLS; +#endif if(selected=="ca") { auth["user"].as([&C, &selected](const std::string& user) { @@ -206,22 +260,32 @@ void ServerConn::handle_CONNECTION_VALIDATION() C->account = user; }); } +#ifdef PVXS_ENABLE_OPENSSL + else if(iface->isTLS && selected=="x509" && bev) { + auto ctx = bufferevent_openssl_get_ssl(bev.get()); + assert(ctx); + ossl::SSLContext::fill_credentials(*C, ctx); + } +#endif if(C->method.empty()) { C->account = C->method = "anonymous"; } C->raw = auth; cred = std::move(C); + log_debug_printf(connsetup, "Client credentials. account: %s, method: %s, authority: %s\n", + cred->account.c_str(), cred->method.c_str(), cred->authority.c_str()); } } - if(selected!="ca" && selected!="anonymous") { + if(selected!="ca" && selected!="anonymous" && selected!="x509") { log_debug_printf(connsetup, "Client %s selects unadvertised auth \"%s\"", peerName.c_str(), selected.c_str()); auth_complete(this, Status{Status::Error, "Client selects unadvertised auth"}); return; } else { - log_debug_printf(connsetup, "Client %s selects auth \"%s\"\n", peerName.c_str(), selected.c_str()); + log_debug_printf(connsetup, "selected-%s: Client %s selects auth \"%s\" as \"%s\" on \"%s\" authority\n", selected.c_str(), + peerName.c_str(), cred->method.c_str(), cred->account.c_str(), cred->authority.c_str()); } // remainder of segBuf is payload w/ credentials @@ -361,6 +425,18 @@ void ServerConn::cleanup() } } +void ServerConn::bevEvent(short events) +{ +#ifdef PVXS_ENABLE_OPENSSL + if((events & (BEV_EVENT_ERROR|BEV_EVENT_EOF)) && iface->isTLS && bev) { + while(auto err = bufferevent_get_openssl_error(bev.get())) { + log_err_printf(connio, "Server: TLS Error (0x%lx) %s\n", err, ERR_reason_error_string(err)); + } + } +#endif + ConnBase::bevEvent(events); +} + void ServerConn::bevRead() { ConnBase::bevRead(); @@ -401,8 +477,11 @@ void ServerConn::bevWrite() } -ServIface::ServIface(const SockAddr &addr, server::Server::Pvt *server, bool fallback) +ServIface::ServIface(const SockAddr &addr, server::Server::Pvt *server, bool fallback, bool isTLS) :server(server) +#ifdef PVXS_ENABLE_OPENSSL + ,isTLS(isTLS) +#endif ,bind_addr(addr) { server->acceptor_loop.assertInLoop(); @@ -448,8 +527,11 @@ ServIface::ServIface(const SockAddr &addr, server::Server::Pvt *server, bool fal listener = evlisten(__FILE__, __LINE__, evconnlistener_new(server->acceptor_loop.base, onConnS, this, LEV_OPT_DISABLED|LEV_OPT_CLOSE_ON_EXEC, backlog, sock.sock)); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wtautological-constant-compare" if(!LEV_OPT_DISABLED) evconnlistener_disable(listener.get()); +#pragma GCC diagnostic pop } void ServIface::onConnS(struct evconnlistener *listener, evutil_socket_t sock, struct sockaddr *peer, int socklen, void *raw) diff --git a/src/serverconn.h b/src/serverconn.h index 3b79e6a4d..fb62937cd 100644 --- a/src/serverconn.h +++ b/src/serverconn.h @@ -7,21 +7,24 @@ #ifndef SERVERCONN_H #define SERVERCONN_H +#include #include #include #include -#include #include #include -#include #include -#include "evhelper.h" -#include "utilpvt.h" +#include + +#include "certstatus.h" +#include "certstatusmanager.h" +#include "conn.h" #include "dataimpl.h" +#include "evhelper.h" #include "udp_collector.h" -#include "conn.h" +#include "utilpvt.h" namespace pvxs {namespace impl { @@ -160,7 +163,7 @@ struct ServerConn final : public ConnBase, public std::enable_shared_from_this current_status; + certs::P12FileWatcher file_watcher; + void* cached_ocsp_response{nullptr}; + certs::cert_status_ptr cert_status_manager; +#endif + INST_COUNTER(ServerPvt); +#ifndef PVXS_ENABLE_OPENSSL Pvt(const Config& conf); +#else + Pvt(const Config& conf, CertEventCallback custom_cert_event_callback = nullptr); +#endif ~Pvt(); void start(); @@ -263,6 +285,17 @@ struct Server::Pvt void onSearch(const UDPManager::Search& msg); void doBeacons(short evt); static void doBeaconsS(evutil_socket_t fd, short evt, void *raw); + +#ifdef PVXS_ENABLE_OPENSSL + static void doCertEventHandler(evutil_socket_t fd, short evt, void* raw); + static void doCertStatusValidityEventhandler(evutil_socket_t fd, short evt, void* raw); + void disableTls(); + void enableTls(const Config& new_config = {}); + void fileEventCallback(short evt); + X509* getCert(ossl::SSLContext* context_ptr = nullptr); + void startStatusValidityTimer(); + void subscribeToCertStatus(); +#endif }; }} // namespace pvxs::server diff --git a/src/serverget.cpp b/src/serverget.cpp index 8c5eba8fb..3a6e639f0 100644 --- a/src/serverget.cpp +++ b/src/serverget.cpp @@ -12,7 +12,7 @@ #include "pvrequest.h" namespace pvxs { namespace impl { -DEFINE_LOGGER(connsetup, "pvxs.tcp.setup"); +DEFINE_LOGGER(connsetup, "pvxs.tcp.init"); DEFINE_LOGGER(connio, "pvxs.tcp.io"); namespace { diff --git a/src/serverintrospect.cpp b/src/serverintrospect.cpp index 8e67151d4..9f31acd4e 100644 --- a/src/serverintrospect.cpp +++ b/src/serverintrospect.cpp @@ -11,7 +11,7 @@ #include "serverconn.h" namespace pvxs { namespace impl { -DEFINE_LOGGER(connsetup, "pvxs.tcp.setup"); +DEFINE_LOGGER(connsetup, "pvxs.tcp.init"); namespace { struct ServerIntrospect final : public ServerOp diff --git a/src/servermon.cpp b/src/servermon.cpp index ce87609f3..dcbad70f9 100644 --- a/src/servermon.cpp +++ b/src/servermon.cpp @@ -17,7 +17,7 @@ #include "pvrequest.h" namespace pvxs { namespace impl { -DEFINE_LOGGER(connsetup, "pvxs.tcp.setup"); +DEFINE_LOGGER(connsetup, "pvxs.tcp.init"); DEFINE_LOGGER(connio, "pvxs.tcp.io"); namespace { diff --git a/src/serversource.cpp b/src/serversource.cpp index 4f8d81937..69b36b876 100644 --- a/src/serversource.cpp +++ b/src/serversource.cpp @@ -11,7 +11,7 @@ namespace pvxs { namespace impl { -DEFINE_LOGGER(srvsrc, "pvxs.server.src"); +DEFINE_LOGGER(srvsrc, "pvxs.svr.src"); ServerSource::ServerSource(server::Server::Pvt* serv) :name("server") @@ -29,7 +29,7 @@ void ServerSource::onSearch(Search &op) void ServerSource::onCreate(std::unique_ptr &&op) { - if(op->name()!=name) + if(!op || op->name()!=name) return; auto handle = std::move(op); // claim diff --git a/src/sharedpv.cpp b/src/sharedpv.cpp index b49dd343e..57dfb4997 100644 --- a/src/sharedpv.cpp +++ b/src/sharedpv.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -22,8 +23,8 @@ typedef epicsGuard Guard; typedef epicsGuardRelease UnGuard; -DEFINE_LOGGER(logshared, "pvxs.server.sharedpv"); -DEFINE_LOGGER(logsource, "pvxs.server.staticsource"); +DEFINE_LOGGER(logshared, "pvxs.svr.pv"); +DEFINE_LOGGER(logsource, "pvxs.svr.src"); DEFINE_LOGGER(logmailbox, "pvxs.mailbox"); namespace pvxs { @@ -223,7 +224,7 @@ void SharedPV::attach(std::unique_ptr&& ctrlop) log_err_printf(logshared, "error in Put cb: %s\n", e.what()); } } else { - op->error("RPC not implemented by this PV"); + op->error("Put not implemented by this PV"); } }); @@ -472,17 +473,63 @@ struct StaticSource::Impl final : public Source { mutable RWLock lock; - list_t pvs; + pv_list_t pvs; decltype (List::names) list; + /** + * @brief Claims all the searched names specified in a search operation for + * this static source. + * + * This method will iterate over the list of searched names contained in the + * search operation. + * + * In each iteration it will first ignore all names that contain wild card + * characters '*' and '?' + * + * Then it will try to directly match the searched name with one of the + * names associated with this static source. + * + * If it finds a match it will claim the searched name so that processing, + * and optionally a response, can take place. + * + * If no direct match is found then it will try an enhanced match + * implementing the wildcard matches in epics-base. e.g. `pattern` + * "pv:name:*" will match with `searched_name` "pv:name:123Abc" and + * `pattern` "pv:name:????" will match with `searched_name` "pv:name:12Ab". + * + * Again, if a match is found the searched + * name will be claimed . + * + * @param op The 'Search' object that contains the searched names to + * be matched. + * + * @return void, but claims all searched names that match either directly or + * against patterns + */ virtual void onSearch(Search &op) override { auto G(lock.lockReader()); + for(auto& name : op) { - auto it(pvs.find(name.name())); - if(it!=pvs.end()) { + const auto searched_name = std::string(name.name()); + + // Don't allow `searched_name`s containing EPICS wildcard characters + if (std::find_first_of( + searched_name.begin(), searched_name.end(), + kEpicsWildcardChars.begin(), kEpicsWildcardChars.end() + ) != searched_name.end()) { + continue; + } + + // Try a direct match of the `searched_name` in `pvs` map or a wildcard match if that fails + SharedPV pv; + SharedWildcardPV wildcard_pv; + if(simpleMatch(searched_name, pv) ) { + name.claim(); + log_debug_printf(logsource, "%p claim '%s'\n", this, searched_name.c_str()); + } else if(wildcardMatch(searched_name, wildcard_pv)) { name.claim(); - log_debug_printf(logsource, "%p claim '%s'\n", this, name.name()); + log_debug_printf(logsource, "%p claim '%s'\n", this, searched_name.c_str()); } } } @@ -490,18 +537,22 @@ struct StaticSource::Impl final : public Source virtual void onCreate(std::unique_ptr &&op) override { SharedPV pv; + SharedWildcardPV wildcard_pv; { auto G(lock.lockReader()); - auto it(pvs.find(op->name())); - bool found = it!=pvs.end(); - log_debug_printf(logsource, "%p %screate '%s'\n", - this, found ? "":"can't ", op->name().c_str()); - if(!found) - return; // not mine - pv = it->second; + const auto searched_name = op->name(); + + if(simpleMatch(searched_name, pv)) { + log_debug_printf(logsource, "%p create '%s'\n", this, searched_name.c_str()); + pv.attach(std::move(op)); + } else if(wildcardMatch(searched_name, wildcard_pv)) { + log_debug_printf(logsource, "%p create '%s'\n", this, searched_name.c_str()); + wildcard_pv.attach(std::move(op), wildcard_pv.getParameters(searched_name)); + } else { + // not mine + log_debug_printf(logsource, "%p can't create '%s'\n", this, searched_name.c_str()); + } } - - pv.attach(std::move(op)); } virtual List onList() override @@ -533,8 +584,86 @@ struct StaticSource::Impl final : public Source // TODO: details for SharedPV } } + + private: + bool simpleMatch(const std::string &searched_name, SharedPV &pv) { + auto it(pvs.find(searched_name)); + if((it)!=pvs.end()) { + pv = *it->second; + return true; + } + return false; + }; + + static const std::string kEpicsWildcardChars; + +/** + * @brief Enhanced wildcard search + * + * Enhanced search will try to match `searched_name` based on + * EPICS wildcard matches (as in epics-base) + * `pattern` "pv:name:*" => `searched_name` "pv:name:123Abc" + * `pattern` "pv:name:????" => `searched_name" "pv:name:12Ab" + * + * @param searched_name the name presented to the server in the search message + * @param pv that wildcard pv that matched the wildcard_pv_name + * @return true if a match is found + */ + bool wildcardMatch(const std::string &searched_name, SharedWildcardPV &pv) { + static const std::regex kRegexSpecialChars{R"([-[\]{}()+.,\^$|#\s])"}; + static const std::regex kWildcardStarPattern("\\*"); + static const char kWildcardQueryCharacter = '?'; + + // Consider only PVs containing EPICS wildcard characters (others already checked) + std::vector>> wildcard_pv_names; + std::copy_if(pvs.begin(), pvs.end(), std::back_inserter(wildcard_pv_names), containsEpicsWildcard); + + for (const auto &wildcard_shared_pv_pair : wildcard_pv_names) { + // 1. Prepare PV regex pattern converting from the EPICS wildcard-style patterns to regex syntax + std::string wildcard_pv = wildcard_shared_pv_pair.first; + + // 1.1 Escape all regex special characters in the original PV pattern + wildcard_pv = std::regex_replace(wildcard_pv, kRegexSpecialChars, R"(\\$&)"); + + // 1.2 Replace Query and Star EPICS wildcard characters with their regex equivalents + std::replace(wildcard_pv.begin(), wildcard_pv.end(), kWildcardQueryCharacter, '.'); + wildcard_pv = std::regex_replace(wildcard_pv, kWildcardStarPattern, ".*"); + + // 2. Compare the PV regex pattern with the `searched_name` + std::regex pv_regex_pattern(wildcard_pv); + if (std::regex_match(searched_name, pv_regex_pattern)) { + try { + std::shared_ptr base_pv = wildcard_shared_pv_pair.second; + std::shared_ptr derived_pv = std::dynamic_pointer_cast(base_pv); + if (!derived_pv) { + throw std::bad_cast(); + } + pv = *derived_pv; // Assign or use as needed + pv.wildcard_pv = wildcard_shared_pv_pair.first; + } catch (const std::bad_cast& e) { + throw std::runtime_error(std::string("Programming error: use SharedWildcardPVs for wildcard PVs: ") + wildcard_shared_pv_pair.first); + } + return true; + } + } + return false; + } + + /** + * Given a pattern / source pair return true if the pattern contains any EPICS wildcard characters + * Suitable for use as `std::copy_if()` predicate + */ + static bool containsEpicsWildcard(const std::pair>& pv_pattern_source) { + const std::string &pv_pattern = pv_pattern_source.first; + return std::find_first_of( + pv_pattern.begin(), pv_pattern.end(), + kEpicsWildcardChars.begin(), kEpicsWildcardChars.end() + ) != pv_pattern.end(); + } }; +const std::string StaticSource::Impl::kEpicsWildcardChars = "*?"; + StaticSource StaticSource::build() { StaticSource ret; @@ -560,7 +689,7 @@ void StaticSource::close() auto G(impl->lock.lockReader()); for(auto& pair : impl->pvs) { - pair.second.close(); + pair.second->close(); } } } @@ -575,7 +704,24 @@ StaticSource& StaticSource::add(const std::string& name, const SharedPV &pv) if(impl->pvs.find(name)!=impl->pvs.end()) throw std::logic_error("add() will not create duplicate PV"); - impl->pvs[name] = pv; + impl->pvs[name] = std::make_shared(pv); + impl->list.reset(); + + return *this; +} + +StaticSource& StaticSource::add(const std::string& name, const SharedWildcardPV& pv) +{ + if (!impl) + throw std::logic_error("Empty StaticSource"); + + auto G(impl->lock.lockWriter()); + + if (impl->pvs.find(name) != impl->pvs.end()) + throw std::logic_error("add() will not create duplicate PV"); + + // Store as shared_ptr + impl->pvs[name] = std::make_shared(pv); impl->list.reset(); return *this; @@ -593,7 +739,7 @@ StaticSource& StaticSource::remove(const std::string& name) auto it(impl->pvs.find(name)); if(it==impl->pvs.end()) return *this; - pv = it->second; + pv = *it->second; impl->pvs.erase(it); impl->list.reset(); } @@ -612,8 +758,11 @@ StaticSource::list_t StaticSource::list() const { auto G(impl->lock.lockReader()); - - return impl->pvs; // copies map + // Create list_t from impl->pvs + for (const auto& pair : impl->pvs) { + ret[pair.first] = *(pair.second); + } + return ret; } } diff --git a/src/sharedwildcardpv.cpp b/src/sharedwildcardpv.cpp new file mode 100644 index 000000000..0fa8cb61e --- /dev/null +++ b/src/sharedwildcardpv.cpp @@ -0,0 +1,474 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include "utilpvt.h" +#include "dataimpl.h" + +typedef epicsGuard Guard; +typedef epicsGuardRelease UnGuard; + +DEFINE_LOGGER(logshared, "pvxs.svr.pvwild"); +DEFINE_LOGGER(logmailbox, "pvxs.mailbox"); + +namespace pvxs { +namespace server { + +template +using ptr_set = std::set>; + +struct SharedWildcardPV::Impl : public std::enable_shared_from_this +{ + mutable epicsMutex lock; + + std::function&&, const std::string &pv_name, const std::list ¶meters, Value&&)> onPut; + std::function&&, const std::string &pv_name, const std::list ¶meters, Value&&)> onRPC; + std::function ¶meters)> onFirstConnect; + std::function ¶meters)> onLastDisconnect; + + std::map>> channels; + + std::map>> pending; + std::map>> mpending; + std::map>> subscribers; + + std::map current_vals; + + static + void connectOp(const std::shared_ptr& self, const std::shared_ptr& conn, const Value& current) + { + try{ + // unlocked as connect() will sync. with the client worker + conn->connect(current); + }catch(std::exception& e){ + log_warn_printf(logshared, "%s Client %s: Can't attach() get: %s\n", + conn->name().c_str(), conn->peerName().c_str(), e.what()); + // not re-throwing for consistency + // we couldn't deliver an error after pending + conn->error(e.what()); + } + } + + static + void connectSub(Guard& G, + const std::shared_ptr& self, + const std::shared_ptr& conn, + const Value& current) + { + G.assertIdenticalMutex(self->lock); + try { + std::shared_ptr sub; + { + UnGuard U(G); + + // unlock as connect() and onClose() sync. with the client worker + sub = conn->connect(current); + + conn->onClose([self, sub](const std::string& msg) { + log_debug_printf(logshared, "%s on %s Monitor onClose\n", sub->peerName().c_str(), sub->name().c_str()); + Guard G(self->lock); + self->subscribers[sub->name()].erase(sub); + }); + + sub->post(current); + } + self->subscribers[sub->name()].emplace(std::move(sub)); + + }catch(std::exception& e){ + UnGuard U(G); + log_warn_printf(logshared, "%s Client %s: Can't attach() monitor: %s\n", + conn->name().c_str(), conn->peerName().c_str(), e.what()); + // not re-throwing for consistency + // we couldn't deliver an error after pending + conn->error(e.what()); + } + } +}; + +SharedWildcardPV SharedWildcardPV::buildMailbox() +{ + SharedWildcardPV ret; + ret.impl = std::make_shared(); + + ret.onPut([](SharedWildcardPV& pv, std::unique_ptr&& op, const std::string &pv_name, const std::list ¶meters, Value&& val) { + auto ts(val["timeStamp"]); + if(ts && !ts.isMarked(true, true)) { + // use current time + epicsTimeStamp now; + if(!epicsTimeGetCurrent(&now)) { + ts["secondsPastEpoch"] = now.secPastEpoch + POSIX_TIME_AT_EPICS_EPOCH; + ts["nanoseconds"] = now.nsec; + } + } + + log_debug_printf(logmailbox, "%s on %s mailbox put: %s\n", + op->peerName().c_str(), op->name().c_str(), + std::string(SB()<reply(); + }); + + return ret; +} + +SharedWildcardPV SharedWildcardPV::buildReadonly() +{ + SharedWildcardPV ret; + ret.impl = std::make_shared(); + + ret.onPut([](SharedWildcardPV& pv, std::unique_ptr&& op, const std::string &pv_name, const std::list ¶meters, Value&& val) { + op->error(SB() << "Read-only PV: " << pv_name); + }); + + return ret; +} + +SharedWildcardPV::~SharedWildcardPV() {} + +void SharedWildcardPV::attach(std::unique_ptr&& ctrlop, const std::list parameters) +{ + // in, or after, some Source::onCreate() + + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + + auto self(impl); // to be captured + + std::shared_ptr ctrl(std::move(ctrlop)); + + log_debug_printf(logshared, "%s on %s Chan setup\n", ctrl->peerName().c_str(), ctrl->name().c_str()); + + ctrl->onRPC([self, parameters](std::unique_ptr&& op, Value&& arg) { + // on server worker + + log_debug_printf(logshared, "%s on %s RPC\n", op->peerName().c_str(), op->name().c_str()); + + Guard G(self->lock); + auto cb(self->onRPC); + if(cb) { + SharedWildcardPV pv; + pv.impl = self; + try { + UnGuard U(G); + cb(pv, std::move(op), op->name(), parameters, std::move(arg)); + }catch(std::exception& e){ + log_err_printf(logshared, "error in RPC cb(%s): %s\n", op->name().c_str(), e.what()); + } + } else { + op->error("RPC not implemented by this PV"); + } + }); + + ctrl->onOp([this, self,parameters](std::unique_ptr&& op) { + // on server worker + + std::shared_ptr conn(std::move(op)); + + log_debug_printf(logshared, "%s on %s Op connecting\n", conn->peerName().c_str(), conn->name().c_str()); + + conn->onGet([self](std::unique_ptr&& op) { + // on server worker + + log_debug_printf(logshared, "%s on %s Get\n", op->peerName().c_str(), op->name().c_str()); + + Value got; + { + Guard G(self->lock); + if(self->current_vals[op->name()]) + got = self->current_vals[op->name()].clone(); + } + if(got) { + op->reply(got); + } else { + op->error("Get races with type change"); + } + + }); + + conn->onPut([self,parameters](std::unique_ptr&& op, Value&& val) { + // on server worker + + log_debug_printf(logshared, "%s on %s RPC\n", op->peerName().c_str(), op->name().c_str()); + + Guard G(self->lock); + auto cb(self->onPut); + if(cb) { + try { + SharedWildcardPV pv; + pv.impl = self; + UnGuard U(G); + cb(pv, std::move(op), op->name(), parameters, std::move(val)); + }catch(std::exception& e){ + log_err_printf(logshared, "error in Put cb: %s\n", e.what()); + } + } else { + op->error("Put not implemented by this PV"); + } + + }); + + conn->onClose([self, conn](const std::string&) { + // on server worker + + log_debug_printf(logshared, "%s on %s OP onClose\n", conn->peerName().c_str(), conn->name().c_str()); + + self->pending[conn->name()].erase(conn); + }); + + Guard G(self->lock); + if(!exists(self->current_vals, conn->name())) { + // no type + self->pending[conn->name()].insert(std::move(conn)); + + } else { + Value temp(self->current_vals[conn->name()]); + UnGuard U(G); + Impl::connectOp(self, conn, temp); + } + }); + + ctrl->onSubscribe([self](std::unique_ptr&& op) { + // on server worker + + log_debug_printf(logshared, "%s on %s Monitor setup\n", op->peerName().c_str(), op->name().c_str()); + + std::shared_ptr conn(std::move(op)); + + Guard G(self->lock); + + if(!self->current_vals[conn->name()]) { + // no type + + // this onClose will be later replaced if/when the monitor is open()'d + conn->onClose([self, conn](const std::string& msg) { + log_debug_printf(logshared, "%s on %s Monitor onClose\n", conn->peerName().c_str(), conn->name().c_str()); + Guard G(self->lock); + self->mpending[conn->name()].erase(conn); + }); + + self->mpending[conn->name()].insert(std::move(conn)); + + } else { + Impl::connectSub(G, self, conn, self->current_vals[conn->name()].clone()); + } + }); + + ctrl->onClose([self, ctrl, parameters](const std::string& msg) { + // on server worker + + log_debug_printf(logshared, "%s on %s Chan close\n", ctrl->peerName().c_str(), ctrl->name().c_str()); + + Guard G(self->lock); + + self->channels[ctrl->name()].erase(ctrl); + + if(self->channels[ctrl->name()].empty()) + log_debug_printf(logshared, "%s on %s onLastDisconnect()\n", ctrl->peerName().c_str(), ctrl->name().c_str()); + + if(self->channels[ctrl->name()].empty() && self->onLastDisconnect) { + auto cb(self->onLastDisconnect); + UnGuard U(G); + SharedWildcardPV pv; + pv.impl = self; + cb(pv, ctrl->name(), parameters); + } + }); + + Guard G(self->lock); + + bool first = impl->channels[ctrl->name()].empty(); + impl->channels[ctrl->name()].insert(ctrl); + + if(first) + log_debug_printf(logshared, "%s on %s onFirstConnect()\n", ctrl->peerName().c_str(), ctrl->name().c_str()); + + if(first && self->onFirstConnect) { + auto cb(self->onFirstConnect); + UnGuard U(G); + SharedWildcardPV pv; + pv.impl=self; + pv.wildcard_pv = wildcard_pv; + cb(pv, ctrl->name(), parameters); + } +} + +void SharedWildcardPV::onFirstConnect(std::function &)>&& fn) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + Guard G(impl->lock); + impl->onFirstConnect = std::move(fn); +} + +void SharedWildcardPV::onLastDisconnect(std::function &)>&& fn) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + Guard G(impl->lock); + impl->onLastDisconnect = std::move(fn); +} + +void SharedWildcardPV::onPut(std::function &&, const std::string &, const std::list &, Value &&)> &&fn) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + Guard G(impl->lock); + impl->onPut = std::move(fn); +} + +void SharedWildcardPV::onRPC(std::function&&, const std::string &, const std::list &, Value&&)>&& fn) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + Guard G(impl->lock); + impl->onRPC = std::move(fn); +} + +// Checks existence without creating entry in map +template +bool SharedWildcardPV::exists(const std::map& m, const std::string& ref) const { + auto it = m.find(ref); + return (it != m.end() && !!(it->second)); +} + +void SharedWildcardPV::open(const std::string &pv_name, const Value& initial) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + else if(!initial || initial.type()!=TypeCode::Struct) + throw std::logic_error("Must specify non-empty initial Struct"); + + auto &pending = impl->pending[pv_name]; + auto &mpending = impl->mpending[pv_name]; + + Value temp; + { + Guard G(impl->lock); + + if(exists(impl->current_vals, pv_name)) + throw std::logic_error("close() first"); + + pending = std::move(impl->pending[pv_name]); + mpending = std::move(impl->mpending[pv_name]); + + impl->current_vals[pv_name] = initial.clone(); + // make a second copy as 'temp' will be queued + temp = initial.clone(); + + // TODO these loops will be really inefficient if we aren't on a worker. + // API to batch? + + for(auto& op : mpending) { + Impl::connectSub(G, impl, op, temp); + // initial open post()'d + } + } + + for(auto& op : pending) { + Impl::connectOp(impl, op, temp); + } +} + +bool SharedWildcardPV::isOpen(const std::string &pv_name) const +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + Guard G(impl->lock); + return exists(impl->current_vals, pv_name); +} + +void SharedWildcardPV::close(const std::string &pv_name) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + + auto &channels = impl->channels[pv_name]; + + { + Guard G(impl->lock); + + if(exists(impl->current_vals, pv_name)) + impl->current_vals[pv_name] = Value(); + + impl->subscribers[pv_name].clear(); + channels = std::move(impl->channels[pv_name]); + } + + for(auto& ch : channels) { + if(auto chan = ch.lock()) + chan->close(); + } +} + +void SharedWildcardPV::post(const std::string &pv_name, const Value& val) +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + else if(!val) + throw std::logic_error("Can't post() empty Value"); + + Guard G(impl->lock); + + if(!exists(impl->current_vals, pv_name)) + throw std::logic_error("Must open() before post()ing"); + else if(Value::Helper::desc(impl->current_vals[pv_name])!=Value::Helper::desc(val)) + throw std::logic_error("post() requires the exact type of open(). Recommend pvxs::Value::cloneEmpty()"); + + impl->current_vals[pv_name].assign(val); + + if(impl->subscribers[pv_name].empty()) + return; + + auto copy(val.clone()); + + for(auto& sub : impl->subscribers[pv_name]) { + sub->post(copy); + } +} + +void SharedWildcardPV::fetch(const std::string &pv_name, Value& val) const +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + + Guard G(impl->lock); + + if(exists(impl->current_vals, pv_name)) { + val.assign(impl->current_vals[pv_name]); + } else { + throw std::logic_error("open() first"); + } +} + +Value SharedWildcardPV::fetch(const std::string &pv_name) const +{ + if(!impl) + throw std::logic_error("Empty SharedWildcardPV"); + + Guard G(impl->lock); + + if(exists(impl->current_vals, pv_name)) { + return impl->current_vals[pv_name].clone(); + } else { + throw std::logic_error("open() first"); + } +} + +} // namespace server +} // namespace pvxs diff --git a/src/udp_collector.cpp b/src/udp_collector.cpp index 8e0366e7a..d97c4d63a 100644 --- a/src/udp_collector.cpp +++ b/src/udp_collector.cpp @@ -29,7 +29,7 @@ typedef epicsGuard Guard; namespace pvxs {namespace impl { DEFINE_LOGGER(logio, "pvxs.udp.io"); -DEFINE_LOGGER(logsetup, "pvxs.udp.setup"); +DEFINE_LOGGER(logsetup, "pvxs.udp.init"); DEFINE_INST_COUNTER(UDPListener); @@ -256,7 +256,7 @@ bool UDPCollector::handle_one() dest.setPort(bind_addr.port()); if(src.isMCast()) { - // should never happen. It it does, we won't be tricked into amplifying a DDoS. + // should never happen. If it does, we won't be tricked into amplifying a DDoS. log_debug_printf(logio, "Ignoring UDP with mcast source %s.\n", src.tostring().c_str()); return true; } @@ -343,9 +343,10 @@ void UDPCollector::process_one(const SockAddr &dest, const uint8_t *buf, size_t dest.tostring().c_str()); } - // so far, only "tcp" transport has ever been seen. + // so far, only "tcp" and "tls" transport has ever been seen. // however, we will pass through others which might appear otherproto.clear(); + protoTCP = protoTLS = false; Size nproto{0}; from_wire(M, nproto); for(size_t i=0; i(const std::string& s) { return ret; } +template <> +bool parseTo(const std::string &s) { + std::string lower; + std::transform(s.begin(), s.end(), std::back_inserter(lower), ::tolower); + return lower == "yes" ||lower == "on" || lower == "enabled" || lower == "true" || lower == "1"; +} + static std::vector splitLines(const char *inp) diff --git a/src/utilpvt.h b/src/utilpvt.h index e8ffb819a..df7f63669 100644 --- a/src/utilpvt.h +++ b/src/utilpvt.h @@ -26,6 +26,8 @@ #include #include +#include + #include #include @@ -48,6 +50,14 @@ #include +// hooks for std::unique_ptr +namespace std { +template<> +struct default_delete { + inline void operator()(FILE* fp) { if(fp) fclose(fp); } +}; +} + namespace pvxs {namespace impl { template @@ -154,6 +164,9 @@ uint64_t parseTo(const std::string& s); template<> PVXS_API int64_t parseTo(const std::string& s); +template<> +PVXS_API +bool parseTo(const std::string& s); #ifdef _WIN32 # define RWLOCK_TYPE SRWLOCK diff --git a/test/Makefile b/test/Makefile index 9cd3cb321..af3dfc72b 100644 --- a/test/Makefile +++ b/test/Makefile @@ -3,16 +3,21 @@ TOP=.. include $(TOP)/configure/CONFIG # cfg/ sometimes isn't correctly included due to a Base bug # so we do here (maybe again) as workaround -include $(TOP)/configure/CONFIG_PVXS_MODULE -include $(TOP)/configure/CONFIG_PVXS_VERSION +-include $(wildcard $(TOP)/cfg/CONFIG*) #---------------------------------------- # ADD MACRO DEFINITIONS AFTER THIS LINE #============================= -# access to private headers +# access to private headers and source USR_CPPFLAGS += -I$(TOP)/src USR_CPPFLAGS += -I$(TOP)/ioc +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -I$(TOP)/certs +SRC_DIRS += $(TOP)/certs +SRC_DIRS += $(TOP)/src +endif + PROD_LIBS = pvxs Com TESTPROD_HOST += testsock @@ -116,6 +121,52 @@ TESTPROD_HOST += testudpfwd testudpfwd_SRCS += testudpfwd.cpp TESTS += testudpfwd +ifdef BASE_3_15 +ifeq ($(EVENT2_HAS_OPENSSL),YES) +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL +TESTPROD_HOST += gen_test_certs +gen_test_certs_SRCS += gen_test_certs.cpp +gen_test_certs_SRCS += certfactory.cpp + +TESTPROD_HOST += testtls +testtls_SRCS += testtls.cpp +TESTS += testtls +TESTFILES += ca.p12 client1.p12 client2.p12 intermediateCA.p12 ioc1.p12 server1.p12 server2.p12 superserver1.p12 + +TESTPROD_HOST += testtlswithcms +testtlswithcms_SRCS += testtlswithcms.cpp +testtlswithcms_SRCS += certstatusfactory.cpp +testtlswithcms_SRCS += certstatusmanager.cpp +testtlswithcms_SRCS += certstatus.cpp +TESTS += testtlswithcms +TESTFILES += ca.p12 client1.p12 client2.p12 intermediateCA.p12 ioc1.p12 server1.p12 server2.p12 superserver1.p12 + +TESTPROD_HOST += testtlswithcmsandstapling +testtlswithcmsandstapling_SRCS += testtlswithcmsandstapling.cpp +testtlswithcmsandstapling_SRCS += certstatusfactory.cpp +testtlswithcmsandstapling_SRCS += certstatusmanager.cpp +testtlswithcmsandstapling_SRCS += certstatus.cpp +TESTS += testtlswithcmsandstapling +TESTFILES += ca.p12 client1.p12 client2.p12 intermediateCA.p12 ioc1.p12 server1.p12 server2.p12 superserver1.p12 + +TESTPROD_HOST += testtlstime +testtlstime_SRCS += testtlstime.cpp +TESTS += testtlstime + +TESTPROD_HOST += testtlsstatus +testtlsstatus_SRCS += testtlsstatus.cpp +testtlsstatus_SRCS += certstatusfactory.cpp +testtlsstatus_SRCS += certstatusmanager.cpp +testtlsstatus_SRCS += certstatus.cpp +TESTS += testtlsstatus + +TESTPROD_HOST += jwtlogintest +jwtlogintest_SRCS += jwtlogintest.cpp +jwtlogintest_SYS_LIBS +=curl + +endif # EVENT2_HAS_OPENSSL +endif + ifdef BASE_7_0 TESTPROD_HOST += benchdata @@ -189,11 +240,26 @@ endif #=========================== include $(TOP)/configure/RULES -include $(TOP)/configure/RULES_PVXS_MODULE +-include $(wildcard $(TOP)/cfg/RULES*) #---------------------------------------- # ADD RULES AFTER THIS LINE ifdef BASE_3_15 rtemsTestData.c : $(TESTFILES) $(TOOLS)/epicsMakeMemFs.pl $(PERL) $(TOOLS)/epicsMakeMemFs.pl $@ epicsRtemsFSImage $(TESTFILES) + +ifeq ($(EVENT2_HAS_OPENSSL),YES) +testtls$(EXE): | ca.p12 + +# generate test certs only with EPICS_HOST_ARCH +ifdef T_A +ca.p12 : + $(RM) *.p12 + ../O.$(EPICS_HOST_ARCH)/gen_test_certs$(HOSTEXE) -O . +ifeq ($(T_A),$(EPICS_HOST_ARCH)) +ca.p12 : gen_test_certs$(HOSTEXE) +endif # T_A==EPICS_HOST_ARCH +endif # T_A +endif # EVENT2_HAS_OPENSSL + endif diff --git a/test/gen_test_certs.cpp b/test/gen_test_certs.cpp new file mode 100644 index 000000000..aae348d30 --- /dev/null +++ b/test/gen_test_certs.cpp @@ -0,0 +1,509 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#ifdef _WIN32 +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "p12filefactory.h" +#include "certfactory.h" +#include "ownedptr.h" +#include "openssl.h" + +#define TEST_FIRST_SERIAL 9876543210 + +namespace { + +struct SSLError : public std::runtime_error { + explicit + SSLError(const std::string& msg) + :std::runtime_error([&msg]() -> std::string { + std::ostringstream strm; + const char *file = nullptr; + int line = 0; + const char *data = nullptr; + int flags = 0; + while(auto err = ERR_get_error_all(&file, &line, nullptr, &data, &flags)) { + strm< + SB& operator<<(const T& i) { strm< newattrs(sk_X509_ATTRIBUTE_deep_copy(curattrs, + &X509_ATTRIBUTE_dup, + &X509_ATTRIBUTE_free)); + + pvxs::ossl_ptr trust(OBJ_txt2obj("anyExtendedKeyUsage", 0)); + pvxs::ossl_ptr attr(X509_ATTRIBUTE_create(NID_oracle_jdk_trustedkeyusage, + V_ASN1_OBJECT, trust.get())); + + MUST(1, sk_X509_ATTRIBUTE_push(newattrs.get(), attr.get())); + attr.release(); + + PKCS12_SAFEBAG_set0_attrs(bag, newattrs.get()); + newattrs.release(); + + return 1; + } catch(std::exception& e){ + std::cerr<<"Error: unable to add JDK trust attribute: "< ASN1_OCTET_STRING + * NID_authority_key_identifier <-> AUTHORITY_KEYID + * NID_basic_constraints <-> BASIC_CONSTRAINTS + * NID_key_usage <-> ASN1_BIT_STRING + * NID_ext_key_usage <-> EXTENDED_KEY_USAGE + * + * Use X509V3_CTX automates building these values in the correct way, + * and than calls low level X509_add1_ext_i2d() + * + * see also "man x509v3_config" for explaination of "expr" string. + */ +void add_extension(X509* cert, int nid, const char *expr, + const X509* subject = nullptr, const X509* issuer = nullptr) +{ + X509V3_CTX xctx; // well, this is different... + X509V3_set_ctx_nodb(&xctx); + X509V3_set_ctx(&xctx, const_cast(issuer), const_cast(subject), nullptr, nullptr, 0); + + pvxs::ossl_ptr ext(X509V3_EXT_conf_nid(nullptr, &xctx, nid, + expr)); + MUST(1, X509_add_ext(cert, ext.get(), -1)); +} + +// for writing a PKCS#12 files, right? +struct PKCS12Writer { + const std::string& outdir; + const char* friendlyName = nullptr; + EVP_PKEY* key = nullptr; + X509* cert = nullptr; + pvxs::ossl_ptr cacerts; + + explicit PKCS12Writer(const std::string& outdir) + :outdir(outdir) + ,cacerts(sk_X509_new_null()) + {} + + void write(const char* fname, + const char *passwd = "") const { + pvxs::ossl_ptr p12(PKCS12_create_ex2(passwd, + friendlyName, + key, + cert, + cacerts.get(), + 0, 0, 0, 0, 0, + nullptr, nullptr, + &jdk_trust, nullptr)); + + std::string outpath(SB()<, pvxs::ossl_ptr> create(bool add_status_extension=true) + { + // generate public/private key pair + pvxs::ossl_ptr key; + { + pvxs::ossl_ptr kctx(EVP_PKEY_CTX_new_id(keytype, NULL)); + MUST(1, EVP_PKEY_keygen_init(kctx.get())); + MUST(1, EVP_PKEY_CTX_set_rsa_keygen_bits(kctx.get(), keylen)); + MUST(1, EVP_PKEY_keygen(kctx.get(), key.acquire())); + } + + // start assembling certificate + pvxs::ossl_ptr cert(X509_new()); + MUST(1, X509_set_version(cert.get(), 2)); + + MUST(1, X509_set_pubkey(cert.get(), key.get())); + + // symbolic name for this cert. Could have multiple entries. + // but we only add commonName (CN) + { + auto sub(X509_get_subject_name(cert.get())); + if(CN) + MUST(1, X509_NAME_add_entry_by_txt(sub, "CN", MBSTRING_ASC, + reinterpret_cast(CN), + -1, -1, 0)); + MUST(1, X509_NAME_add_entry_by_txt(sub, "C", MBSTRING_ASC, + reinterpret_cast("US"), + -1, -1, 0)); + MUST(1, X509_NAME_add_entry_by_txt(sub, "O", MBSTRING_ASC, + reinterpret_cast("ca.epics.org"), + -1, -1, 0)); + MUST(1, X509_NAME_add_entry_by_txt(sub, "OU", MBSTRING_ASC, + reinterpret_cast("epics.org Certificate Authority"), + -1, -1, 0)); + } + if(!issuer) { + issuer = cert.get(); // self-signed + ikey = key.get(); + + } else if(!ikey) { + throw std::runtime_error("no issuer key"); + } + + // symbolic name of certificate which issues this new cert. + MUST(1, X509_set_issuer_name(cert.get(), X509_get_subject_name(issuer))); + + // set valid time range + { + time_t now(time(nullptr)); + pvxs::ossl_ptr before(ASN1_TIME_new()); + ASN1_TIME_set(before.get(), now); + pvxs::ossl_ptr after(ASN1_TIME_new()); + ASN1_TIME_set(after.get(), now+(expire_days*24*60*60)); + MUST(1, X509_set1_notBefore(cert.get(), before.get())); + MUST(1, X509_set1_notAfter(cert.get(), after.get())); + } + + // issuer serial number + if(serial) { + pvxs::ossl_ptr sn(ASN1_INTEGER_new()); + MUST(1, ASN1_INTEGER_set_uint64(sn.get(), serial)); + MUST(1, X509_set_serialNumber(cert.get(), sn.get())); + } + + // certificate extensions... + // see RFC5280 + + // Store a hash of the public key. (kind of redundant to stored public key?) + // RFC5280 mandates this for a CA cert. Optional for others, and very common. + add_extension(cert.get(), NID_subject_key_identifier, "hash", + cert.get()); + + // store hash and name of issuer certificate (or issuer's issuer?) + // RFC5280 mandates this for all certs. + add_extension(cert.get(), NID_authority_key_identifier, "keyid:always,issuer:always", + nullptr, issuer); + + // certificate usage constraints. + + // most basic. Can this certificate be an issuer to other certificates? + // RFC5280 mandates this for a CA cert. (CA:TRUE) Optional for others, but common + add_extension(cert.get(), NID_basic_constraints, isCA ? "critical,CA:TRUE" : "CA:FALSE"); + + if (key_usage) + add_extension(cert.get(), NID_key_usage, key_usage); + + if(extended_key_usage) + add_extension(cert.get(), NID_ext_key_usage, extended_key_usage); + + if ( add_status_extension) { + auto issuerId = pvxs::certs::CertStatus::getIssuerId((X509*)issuer); + pvxs::certs::CertFactory::addCustomExtensionByNid(cert, pvxs::ossl::SSLContext::NID_PvaCertStatusURI, pvxs::certs::CertStatus::makeStatusURI(issuerId, serial), issuer); + } + + auto nbytes(X509_sign(cert.get(), ikey, sig)); + if(nbytes==0) + throw SSLError("Failed to sign cert"); + + return std::make_tuple(std::move(key), std::move(cert)); + } +}; + +void usage(const char* argv0) { + std::cerr<<"Usage: "<]\n" + "\n" + " Write out a test of Certificate files for testing.\n" + "\n" + " -O - Write files to this directory. (default: .)\n" + ; +} + +std::string writeCertToTempFile(pvxs::ossl_ptr &cert) { + std::string temp_file_path = "ca_cert.pem"; + + FILE* temp_file = fopen(temp_file_path.c_str(), "w"); + if (!temp_file) { + throw std::runtime_error("Failed to open temporary file"); + } + + if (!PEM_write_X509(temp_file, cert.get())) { + fclose(temp_file); + throw std::runtime_error("Failed to write certificate to temporary file"); + } + + fclose(temp_file); + return temp_file_path; +} + +void addCertToTruststore(const std::string& cert_path) { + std::string command; +#ifdef __APPLE__ + // macOS + command = "sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain " + cert_path; +#elif defined(__linux__) + // Linux + command = "sudo cp " + cert_path + " /usr/local/share/ca-certificates/ && sudo update-ca-certificates"; +#elif defined(_WIN32) || defined(_WIN64) + // Windows + command = "certutil -addstore -f \"Root\" " + cert_path; +#else + throw std::runtime_error("Unsupported platform"); +#endif + + std::cout << "Root Certificate Created." << std::endl << "Run the following to trust it: " << std::endl << command << std::endl; +// int ret = std::system(command.c_str()); +// if (ret != 0) { +// throw std::runtime_error("Failed to add certificate to trust store"); +// } +} +} // namespace + +int main(int argc, char *argv[]) +{ + try { + pvxs::ossl::SSLContext::sslInit(); + std::string outdir("."); + { + int opt; + while ((opt = getopt(argc, argv, "hO:")) != -1) { + switch(opt) { + case 'h': + usage(argv[0]); + return 0; + case 'O': + outdir = optarg; + if(outdir.empty()) + throw std::runtime_error("-O argument must not be empty"); + break; + default: + usage(argv[0]); + std::cerr<<"\nUnknown argument: "< root_cert; + pvxs::ossl_ptr root_key; + { + CertCreator cc; + cc.CN = "epics.org Root CA"; + cc.serial = serial++; + cc.isCA = true; + cc.key_usage = "cRLSign,keyCertSign"; + + std::tie(root_key, root_cert) = cc.create(); + + PKCS12Writer p12(outdir); + p12.friendlyName = cc.CN; + p12.key = root_key.get(); + MUST(1, sk_X509_push(p12.cacerts.get(), root_cert.get())); + p12.write("ca.p12"); + + std::string temp_cert_path = writeCertToTempFile(root_cert); + addCertToTruststore(temp_cert_path); + pvxs::certs::CertFactory::createCertSymlink(temp_cert_path); + std::cout << "CA Certificate added to trust store successfully." << std::endl; + } + + // a server-type cert. issued directly from the root + { + CertCreator cc; + cc.CN = "superserver1"; + cc.serial = serial++; + cc.key_usage = "digitalSignature"; + cc.extended_key_usage = "serverAuth"; + cc.issuer = root_cert.get(); + cc.ikey = root_key.get(); + + pvxs::ossl_ptr cert; + pvxs::ossl_ptr key; + std::tie(key, cert) = cc.create(false); // Don't add extension so this can be used as Mock PVACMS cert in tests + + PKCS12Writer p12(outdir); + p12.friendlyName = cc.CN; + p12.key = key.get(); + p12.cert = cert.get(); + MUST(1, sk_X509_push(p12.cacerts.get(), root_cert.get())); + p12.write("superserver1.p12"); + } + + // a chain/intermediate certificate authority + pvxs::ossl_ptr i_cert; + pvxs::ossl_ptr i_key; + { + CertCreator cc; + cc.CN = "intermediateCA"; + cc.serial = serial++; + cc.issuer = root_cert.get(); + cc.ikey = root_key.get(); + cc.isCA = true; + cc.key_usage = "digitalSignature,cRLSign,keyCertSign"; + // on a CA cert. this is a mask of usages which it is allowed to delegate. + cc.extended_key_usage = "serverAuth,clientAuth,OCSPSigning"; + + std::tie(i_key, i_cert) = cc.create(); + + PKCS12Writer p12(outdir); + p12.friendlyName = cc.CN; + p12.key = i_key.get(); + p12.cert = i_cert.get(); + MUST(1, sk_X509_push(p12.cacerts.get(), root_cert.get())); + p12.write("intermediateCA.p12"); + } + + // from this point, the epics.org Root CA key is no longer needed. + root_key.reset(); + + // remaining certificates issued by intermediate. + // extendedKeyUsage derived from name: client, server, or IOC (both client and server) + for(const char *name : {"server1", "server2", "ioc1", "client1", "client2"}) { + CertCreator cc; + cc.CN = name; + cc.serial = serial++; + cc.key_usage = "digitalSignature"; + if(strstr(name, "server")) + cc.extended_key_usage = "serverAuth"; + else if(strstr(name, "client")) + cc.extended_key_usage = "clientAuth"; + else if(strstr(name, "ioc")) + cc.extended_key_usage = "clientAuth,serverAuth"; + cc.issuer = i_cert.get(); + cc.ikey = i_key.get(); + + pvxs::ossl_ptr cert; + pvxs::ossl_ptr key; + std::tie(key, cert) = cc.create(); + + PKCS12Writer p12(outdir); + p12.friendlyName = cc.CN; + p12.key = key.get(); + p12.cert = cert.get(); + MUST(1, sk_X509_push(p12.cacerts.get(), i_cert.get())); + MUST(2, sk_X509_push(p12.cacerts.get(), root_cert.get())); + std::string fname(SB()<&1 + exit 1 +} + +OUT="${1:-.}" +PW="${2:-changeit}" + +[ "$OUT" = "-h" -o -d "$OUT" ] || die "usage: $0 [outdir] [password]" + +rm -f \ + "$OUT"/ca-full.p12 "$OUT"/ca.pem "$OUT"/ca.p12 \ + "$OUT"/superserver1.p12 \ + "$OUT"/intermediateCA.p12 "$OUT"/intermediateCA.pem \ + "$OUT"/ioc1.p12 \ + "$OUT"/server1.p12 \ + "$OUT"/server2.p12 \ + "$OUT"/client1.p12 \ + "$OUT"/client2.p12 + +# the root CA private key is not needed during testing, so delete it on exit. +trap 'rm -f "$OUT"/ca-full.p12' EXIT QUIT TERM KILL + +echo "==== Creating rootCA ====" + +keytool -v -genkeypair -alias rootCA \ + -keystore "$OUT"/ca-full.p12 -storepass "$PW" \ + -dname "CN=rootCA" -keyalg RSA \ + -ext BasicConstraints=ca:true \ + -ext KeyUsage=cRLSign,keyCertSign +keytool -v -exportcert -alias rootCA \ + -keystore "$OUT"/ca-full.p12 -storepass "$PW" \ + -rfc -file "$OUT"/ca.pem +keytool -v -importcert -alias rootCA \ + -keystore "$OUT"/ca.p12 -storepass "$PW" \ + -file "$OUT"/ca.pem -noprompt + +echo "==== Creating superserver1 ====" + +keytool -v -genkeypair -alias superserver1 \ + -keystore "$OUT"/superserver1.p12 -storepass "$PW" \ + -dname "CN=dummy" \ + -keyalg RSA +keytool -v -importcert -alias rootCA \ + -keystore "$OUT"/superserver1.p12 -storepass "$PW" \ + -file "$OUT"/ca.pem \ + -noprompt +keytool -v -certreq -alias superserver1 \ + -keystore "$OUT"/superserver1.p12 -storepass "$PW" \ +| keytool -v -gencert -alias rootCA \ + -keystore "$OUT"/ca-full.p12 -storepass "$PW" \ + -dname "CN=superserver1" \ + -ext KeyUsage=digitalSignature -ext ExtendedKeyUsage=serverAuth,clientAuth \ +| keytool -v -importcert -alias superserver1 \ + -keystore "$OUT"/superserver1.p12 -storepass "$PW" + +echo "==== Creating intermediateCA ====" + +keytool -v -genkeypair -alias intermediateCA \ + -keystore "$OUT"/intermediateCA.p12 -storepass "$PW" \ + -dname "CN=dummy" \ + -keyalg RSA +keytool -v -importcert -alias rootCA \ + -keystore "$OUT"/intermediateCA.p12 -storepass "$PW" \ + -file "$OUT"/ca.pem \ + -noprompt +keytool -v -certreq -alias intermediateCA \ + -keystore "$OUT"/intermediateCA.p12 -storepass "$PW" \ +| keytool -v -gencert -alias rootCA \ + -keystore "$OUT"/ca-full.p12 -storepass "$PW" \ + -dname "CN=intermediateCA" \ + -ext BasicConstraints=ca:true \ + -ext KeyUsage=digitalSignature,cRLSign,keyCertSign \ + -ext ExtendedKeyUsage=serverAuth,clientAuth,OCSPSigning \ + -outfile "$OUT"/intermediateCA.pem +keytool -v -importcert -alias intermediateCA \ + -keystore "$OUT"/intermediateCA.p12 -storepass "$PW" \ + -file "$OUT"/intermediateCA.pem + +for name in ioc1 server1 server2 client1 client2 +do + echo "==== Creating $name ====" + + expr match "$name" server >/dev/null && EKU=serverAuth || true + expr match "$name" client >/dev/null && EKU=clientAuth || true + expr match "$name" ioc >/dev/null && EKU=clientAuth,serverAuth || true + + keytool -v -genkeypair -alias "$name" \ + -keystore "$OUT/$name.p12" -storepass "$PW" \ + -dname "CN=dummy" \ + -keyalg RSA + keytool -v -importcert -alias rootCA \ + -keystore "$OUT/$name.p12" -storepass "$PW" \ + -file "$OUT"/ca.pem \ + -noprompt + keytool -v -importcert -alias intermediateCA \ + -keystore "$OUT/$name.p12" -storepass "$PW" \ + -file "$OUT"/intermediateCA.pem \ + -noprompt + keytool -v -certreq -alias "$name" \ + -keystore "$OUT/$name.p12" -storepass "$PW" \ + | keytool -v -gencert -alias intermediateCA \ + -keystore "$OUT"/intermediateCA.p12 -storepass "$PW" \ + -dname "CN=$name" \ + -ext KeyUsage=digitalSignature,keyEncipherment \ + -ext ExtendedKeyUsage="$EKU" \ + | keytool -v -importcert -alias "$name" \ + -keystore "$OUT/$name.p12" -storepass "$PW" + +done + +echo "==== Listing ====" + +for ff in "$OUT"/*.p12 +do + echo "==== Listing $ff ====" + keytool -v -list -keystore "$ff" -storepass "$PW" +done diff --git a/test/jwt-sign-in.mhtml b/test/jwt-sign-in.mhtml new file mode 100644 index 000000000..c3f20cbd2 --- /dev/null +++ b/test/jwt-sign-in.mhtml @@ -0,0 +1,7872 @@ +From: +Snapshot-Content-Location: https://adfs.slac.stanford.edu/adfs/ls/?client-request-id=5e9908e4-76eb-4bdc-2b00-00800120006b +Subject: Sign In +Date: Thu, 19 Sep 2024 00:21:29 -0400 +MIME-Version: 1.0 +Content-Type: multipart/related; + type="text/html"; + boundary="----MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk----" + + +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: text/html +Content-ID: +Content-Transfer-Encoding: quoted-printable +Content-Location: https://adfs.slac.stanford.edu/adfs/ls/?client-request-id=5e9908e4-76eb-4bdc-2b00-00800120006b + + + + + =20 + + + + + + Sign In + =20 + + + + + =20 + + + + +
+

JavaScript required

+

JavaScript is required. This web browser does not support JavaSc= +ript or JavaScript in this web browser is not enabled.

+

To find out if your web browser supports JavaScript or to enable= + JavaScript, see web browser help.

+
+ =20 +
+
+
+
+
=20 +
+ =20 + + + =20 +=20 + + +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: text/css +Content-Transfer-Encoding: quoted-printable +Content-Location: cid:css-92d7fd0c-ac54-403a-900b-a7ec3f6cf53d@mhtml.blink + +@charset "utf-8"; + +.illustrationClass { background-image: url("/adfs/portal/illustration/illus= +tration.jpg?id=3DED4432D4E85205FE23B76AF74C6D16BEA0FCF6E9D400CABA98CAE6BE62= +3E8171"); } +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: image/jpg +Content-Transfer-Encoding: base64 +Content-Location: https://adfs.slac.stanford.edu/adfs/portal/illustration/illustration.jpg?id=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 +b2wAAAAAC3ByaW50ZXJOYW1lVEVYVAAAABIASABQACAATwBmAGYAaQBjAGUASgBlAHQAIAA2ADkA +NQAwAAAAAAAPcHJpbnRQcm9vZlNldHVwT2JqYwAAAAwAUAByAG8AbwBmACAAUwBlAHQAdQBwAAAA +AAAKcHJvb2ZTZXR1cAAAAAEAAAAAQmx0bmVudW0AAAAMYnVpbHRpblByb29mAAAACXByb29mQ01Z +SwA4QklNBDsAAAAAAi0AAAAQAAAAAQAAAAAAEnByaW50T3V0cHV0T3B0aW9ucwAAABcAAAAAQ3B0 +bmJvb2wAAAAAAENsYnJib29sAAAAAABSZ3NNYm9vbAAAAAAAQ3JuQ2Jvb2wAAAAAAENudENib29s +AAAAAABMYmxzYm9vbAAAAAAATmd0dmJvb2wAAAAAAEVtbERib29sAAAAAABJbnRyYm9vbAAAAAAA +QmNrZ09iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdWJAb+AAAAAAAAAAAABHcm4gZG91 +YkBv4AAAAAAAAAAAAEJsICBkb3ViQG/gAAAAAAAAAAAAQnJkVFVudEYjUmx0AAAAAAAAAAAAAAAA +QmxkIFVudEYjUmx0AAAAAAAAAAAAAAAAUnNsdFVudEYjUHhsQFgAAAAAAAAAAAAKdmVjdG9yRGF0 +YWJvb2wBAAAAAFBnUHNlbnVtAAAAAFBnUHMAAAAAUGdQQwAAAABMZWZ0VW50RiNSbHQAAAAAAAAA +AAAAAABUb3AgVW50RiNSbHQAAAAAAAAAAAAAAABTY2wgVW50RiNQcmNAWQAAAAAAAAAAABBjcm9w +V2hlblByaW50aW5nYm9vbAAAAAAOY3JvcFJlY3RCb3R0b21sb25nAAAAAAAAAAxjcm9wUmVjdExl +ZnRsb25nAAAAAAAAAA1jcm9wUmVjdFJpZ2h0bG9uZwAAAAAAAAALY3JvcFJlY3RUb3Bsb25nAAAA +AAA4QklNA+0AAAAAABAAYAAAAAEAAQBgAAAAAQABOEJJTQQmAAAAAAAOAAAAAAAAAAAAAD+AAAA4 +QklNA/IAAAAAAAoAAP///////wAAOEJJTQQNAAAAAAAEAAAAYThCSU0EGQAAAAAABAAAAB44QklN +A/MAAAAAAAkAAAAAAAAAAAEAOEJJTScQAAAAAAAKAAEAAAAAAAAAAThCSU0D9QAAAAAASAAvZmYA +AQBsZmYABgAAAAAAAQAvZmYAAQChmZoABgAAAAAAAQAyAAAAAQBaAAAABgAAAAAAAQA1AAAAAQAt +AAAABgAAAAAAAThCSU0D+AAAAAAAcAAA/////////////////////////////wPoAAAAAP////// +//////////////////////8D6AAAAAD/////////////////////////////A+gAAAAA//////// +/////////////////////wPoAAA4QklNBAAAAAAAAAIACDhCSU0EAgAAAAAAFgAAAAAAAAAAAAAA +AAAAAAAAAAAAAAA4QklNBDAAAAAAAAsBAQEBAQEBAQEBAQA4QklNBC0AAAAAAAYAAQAAAA04QklN +BAgAAAAAABAAAAABAAACQAAAAkAAAAAAOEJJTQQeAAAAAAAEAAAAADhCSU0EGgAAAAADRwAAAAYA +AAAAAAAAAAAABYwAAAQ4AAAACQBsAG8AZwBpAG4ALQBhAHIAdAAAAAEAAAAAAAAAAAAAAAAAAAAA +AAAAAQAAAAAAAAAAAAAEOAAABYwAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAQ +AAAAAQAAAAAAAG51bGwAAAACAAAABmJvdW5kc09iamMAAAABAAAAAAAAUmN0MQAAAAQAAAAAVG9w +IGxvbmcAAAAAAAAAAExlZnRsb25nAAAAAAAAAABCdG9tbG9uZwAABYwAAAAAUmdodGxvbmcAAAQ4 +AAAABnNsaWNlc1ZsTHMAAAABT2JqYwAAAAEAAAAAAAVzbGljZQAAABIAAAAHc2xpY2VJRGxvbmcA +AAAAAAAAB2dyb3VwSURsb25nAAAAAAAAAAZvcmlnaW5lbnVtAAAADEVTbGljZU9yaWdpbgAAAA1h +dXRvR2VuZXJhdGVkAAAAAFR5cGVlbnVtAAAACkVTbGljZVR5cGUAAAAASW1nIAAAAAZib3VuZHNP +YmpjAAAAAQAAAAAAAFJjdDEAAAAEAAAAAFRvcCBsb25nAAAAAAAAAABMZWZ0bG9uZwAAAAAAAAAA +QnRvbWxvbmcAAAWMAAAAAFJnaHRsb25nAAAEOAAAAAN1cmxURVhUAAAAAQAAAAAAAG51bGxURVhU +AAAAAQAAAAAAAE1zZ2VURVhUAAAAAQAAAAAABmFsdFRhZ1RFWFQAAAABAAAAAAAOY2VsbFRleHRJ +c0hUTUxib29sAQAAAAhjZWxsVGV4dFRFWFQAAAABAAAAAAAJaG9yekFsaWduZW51bQAAAA9FU2xp +Y2VIb3J6QWxpZ24AAAAHZGVmYXVsdAAAAAl2ZXJ0QWxpZ25lbnVtAAAAD0VTbGljZVZlcnRBbGln +bgAAAAdkZWZhdWx0AAAAC2JnQ29sb3JUeXBlZW51bQAAABFFU2xpY2VCR0NvbG9yVHlwZQAAAABO +b25lAAAACXRvcE91dHNldGxvbmcAAAAAAAAACmxlZnRPdXRzZXRsb25nAAAAAAAAAAxib3R0b21P +dXRzZXRsb25nAAAAAAAAAAtyaWdodE91dHNldGxvbmcAAAAAADhCSU0EKAAAAAAADAAAAAI/8AAA +AAAAADhCSU0EFAAAAAAABAAAAA44QklNBAwAAAAAEuAAAAABAAAAegAAAKAAAAFwAADmAAAAEsQA +GAAB/9j/7QAMQWRvYmVfQ00AAf/uAA5BZG9iZQBkgAAAAAH/2wCEAAwICAgJCAwJCQwRCwoLERUP +DAwPFRgTExUTExgRDAwMDAwMEQwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwBDQsLDQ4NEA4O +EBQODg4UFA4ODg4UEQwMDAwMEREMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDP/A +ABEIAKAAegMBIgACEQEDEQH/3QAEAAj/xAE/AAABBQEBAQEBAQAAAAAAAAADAAECBAUGBwgJCgsB +AAEFAQEBAQEBAAAAAAAAAAEAAgMEBQYHCAkKCxAAAQQBAwIEAgUHBggFAwwzAQACEQMEIRIxBUFR +YRMicYEyBhSRobFCIyQVUsFiMzRygtFDByWSU/Dh8WNzNRaisoMmRJNUZEXCo3Q2F9JV4mXys4TD +03Xj80YnlKSFtJXE1OT0pbXF1eX1VmZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3EQACAgECBAQDBAUG +BwcGBTUBAAIRAyExEgRBUWFxIhMFMoGRFKGxQiPBUtHwMyRi4XKCkkNTFWNzNPElBhaisoMHJjXC +0kSTVKMXZEVVNnRl4vKzhMPTdePzRpSkhbSVxNTk9KW1xdXl9VZmdoaWprbG1ub2JzdHV2d3h5en +t8f/2gAMAwEAAhEDEQA/APRoTwsNn12+rjmgnItYf3XUWT/0Wuaj1/Wv6uWNkZzGeVjXsP8AmvYl +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 +fxj4XV/9U1SS2Wjd6/fpPbUj/wAxdO1yi4kyI+Ijn+z9JDYSY8SOyM1sD/WB/wB9RAUTeizREOPy +/wDOvoOUyJG35x5/fvamDiPI9zx/5mlu7fh/sA2pJFBsdK2OyHY9jobe3aD4OPt3E/1vTUjuaS1x +cHNMOEs5GhVEP2WssJja4SfLhy08wl1ot49YbiNSA4eyz84f1kzINQe+i/EdCO37UMmdN3+aD+Ry +lDvCfiw/wUD5/kH8ZSAHgPnt/wDIqOmRkWjuB8fc3+CxOsYIre7MqjZYf0zQQdrz/hf6t3+E/wCG +/wCOW4COxA/1/kgKLw17XMf72OBa5p3EEHlrvd+cjE8JtRFinkUgVYzsN2JeazJrdLqnnkt8Hf8A +CV/Rs/8AUirKwDerAQka4tIB47eEJehj/unnd/5h/VQwU8+fZHRD/9Dz8khpjRSwAT1DGHJNrP8A +qgoOJAECfEI/TGn9qYnf9M38NVIUB6ikDaPhyiGPH8ChM4Hw4Uifn89fuRCqZTH+oCYuHE/j/coz +/qBKUuJ0J+UIqYvg6chX8ew3YJaXTZSQ75D2O/6PvVIgkd/vUsOwC41k+20Qe8T7Sf8AqHpSFxP4 +fRETwzHjoWxI8VIEeKg4FrixzjLTBGgOiaG9pPzn+Kh0Z0wd/rKcuP8AvKECeNU+6O8fJCk2g6hi +DLoLJ2ub7mHmHR/rvXNODmuLXCHNMOHgQut3Txqsvq3Txaw5FLD6tYJeAPpsGp0/0lX0/wCXV/xV +afCVaFbON6hxUtwTHxSUrE//2ThCSU0EIQAAAAAAXQAAAAEBAAAADwBBAGQAbwBiAGUAIABQAGgA +bwB0AG8AcwBoAG8AcAAAABcAQQBkAG8AYgBlACAAUABoAG8AdABvAHMAaABvAHAAIABDAEMAIAAy +ADAAMQA5AAAAAQA4QklNBAYAAAAAAAcAAwAAAAEBAP/hF+1odHRwOi8vbnMuYWRvYmUuY29tL3hh +cC8xLjAvADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlk +Ij8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhN +UCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4g +PHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50 +YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8v +bnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpwaG90b3Nob3A9Imh0dHA6Ly9ucy5hZG9iZS5j +b20vcGhvdG9zaG9wLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMv +MS4xLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5z +OnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMi +IHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VS +ZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChNYWNpbnRvc2gp +IiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0xMC0yNlQxMToyNS0wNzowMCIgeG1wOk1ldGFkYXRhRGF0 +ZT0iMjAxOC0xMC0zMFQxNDozMToyNi0wNzowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMTAtMzBU +MTQ6MzE6MjYtMDc6MDAiIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9m +aWxlPSJEaXNwbGF5IiBkYzpmb3JtYXQ9ImltYWdlL2pwZWciIHhtcE1NOkluc3RhbmNlSUQ9Inht +cC5paWQ6MjUzYzljMWUtNThhZi00OGFlLWIzMjQtZjFkNTllYjNkMTIzIiB4bXBNTTpEb2N1bWVu +dElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6MGFhNDY3NzctODU3OS04MjQzLWFjODEtNTI0NGFl +YzI2Zjc3IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6NDdhMTc0N2ItNTg0Ni00 +ZDZlLTg4OWQtM2Y2YWUyMzViNDA2Ij4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4g +PHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSJCT0xEIFBFT1BMRSBWSVNJT05BUlkgU0NJRU5D +RSBSRUFMIElNUEFDVCIgcGhvdG9zaG9wOkxheWVyVGV4dD0iQk9MRCBQRU9QTEUgVklTSU9OQVJZ +IFNDSUVOQ0UgUkVBTCBJTVBBQ1QiLz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSJPcGVu +aW5nIG5ldyB3aW5kb3dzIHRvIHRoZSBuYXR1cmFsIHdvcmxkIGFuZCBidWlsZGluZyBhIGJyaWdo +dGUiIHBob3Rvc2hvcDpMYXllclRleHQ9Ik9wZW5pbmcgbmV3IHdpbmRvd3MgdG8gdGhlIG5hdHVy +YWwgd29ybGQgYW5kIGJ1aWxkaW5nIGEgYnJpZ2h0ZXIgZnV0dXJlIHRocm91Z2ggc2NpZW50aWZp +YyBkaXNjb3ZlcnkuICIvPiA8cmRmOmxpIHBob3Rvc2hvcDpMYXllck5hbWU9Ik9wZW5pbmcgbmV3 +IHdpbmRvd3MgdG8gdGhlIG5hdHVyYWwgd29ybGQgYW5kIGJ1aWxkaW5nIGEgYnJpZ2h0ZSIgcGhv +dG9zaG9wOkxheWVyVGV4dD0iT3BlbmluZyBuZXcgd2luZG93cyB0byB0aGUgbmF0dXJhbCB3b3Js +ZCBhbmQgYnVpbGRpbmcgYSBicmlnaHRlciBmdXR1cmUgdGhyb3VnaCBzY2llbnRpZmljIGRpc2Nv +dmVyeS4gIi8+IDxyZGY6bGkgcGhvdG9zaG9wOkxheWVyTmFtZT0iT3BlbmluZyBuZXcgd2luZG93 +cyB0byB0aGUgIG5hdHVyYWwgd29ybGQgYW5kIGJ1aWxkaW5nIGEgYnJpZ2h0IiBwaG90b3Nob3A6 +TGF5ZXJUZXh0PSJPcGVuaW5nIG5ldyB3aW5kb3dzIHRvIHRoZSAgbmF0dXJhbCB3b3JsZCBhbmQg +YnVpbGRpbmcgYSBicmlnaHRlciBmdXR1cmUgdGhyb3VnaCBzY2llbnRpZmljIGRpc2NvdmVyeS4g +IFNMQUMgVmlzaW9uIFN0YXRlbWVudCAiLz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSJP +cGVuaW5nIG5ldyB3aW5kb3dzIHRvIHRoZSBuYXR1cmFsIHdvcmxkIGFuZCBidWlsZGluZyBhIGJy +aWdodGUiIHBob3Rvc2hvcDpMYXllclRleHQ9Ik9wZW5pbmcgbmV3IHdpbmRvd3MgdG8gdGhlIG5h +dHVyYWwgd29ybGQgYW5kIGJ1aWxkaW5nIGEgYnJpZ2h0ZXIgZnV0dXJlIHRocm91Z2ggc2NpZW50 +aWZpYyBkaXNjb3ZlcnkuICIvPiA8cmRmOmxpIHBob3Rvc2hvcDpMYXllck5hbWU9Ik91ciBNaXNz +aW9uICAgVG8gZXhwbG9yZSBob3cgdGhlIHVuaXZlcnNlIHdvcmtzIGF0IHRoZSBiaWdnZXN0LCBj +b3B5IiBwaG90b3Nob3A6TGF5ZXJUZXh0PSJPdXIgTWlzc2lvbiAgIFRvIGV4cGxvcmUgaG93IHRo +ZSB1bml2ZXJzZSB3b3JrcyBhdCB0aGUgYmlnZ2VzdCwgc21hbGxlc3QgYW5kIGZhc3Rlc3Qgc2Nh +bGVzIGFuZCBpbnZlbnQgcG93ZXJmdWwgdG9vbHMgdXNlZCBieSBzY2llbnRpc3RzIGFyb3VuZCB0 +aGUgZ2xvYmUuIE91ciByZXNlYXJjaCBoZWxwcyBzb2x2ZSByZWFsLXdvcmxkIHByb2JsZW1zIGFu +ZCAgYWR2YW5jZXMgdGhlIGludGVyZXN0cyBvZiB0aGUgbmF0aW9uLiAiLz4gPC9yZGY6QmFnPiA8 +L3Bob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cGhvdG9zaG9wOkRvY3VtZW50QW5jZXN0b3JzPiA8cmRm +OkJhZz4gPHJkZjpsaT41QzNCNjlEODYyNjZGRTk1N0M1Rjg2RkEzNEIzNUVCQjwvcmRmOmxpPiA8 +L3JkZjpCYWc+IDwvcGhvdG9zaG9wOkRvY3VtZW50QW5jZXN0b3JzPiA8eG1wTU06SGlzdG9yeT4g +PHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlE +PSJ4bXAuaWlkOjQ3YTE3NDdiLTU4NDYtNGQ2ZS04ODlkLTNmNmFlMjM1YjQwNiIgc3RFdnQ6d2hl +bj0iMjAxOC0xMC0yNlQxMToyNS0wNzowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhv +dG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVk +IiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjIxYzRhYjBiLTVjODQtNGMzMy04ZjBiLWIxNmZm +OWMyNjA3OCIgc3RFdnQ6d2hlbj0iMjAxOC0xMC0yNlQxMzo1OTozMi0wNzowMCIgc3RFdnQ6c29m +dHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiIHN0RXZ0OmNo +YW5nZWQ9Ii8iLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlE +PSJ4bXAuaWlkOjI3ZjI4OGM5LWQwMTctNDI4OC05MTAyLWE3YmU5YjhmMjM3NCIgc3RFdnQ6d2hl +bj0iMjAxOC0xMC0zMFQxNDozMToyNi0wNzowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUg +UGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPHJkZjps +aSBzdEV2dDphY3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNh +dGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL2pwZWciLz4gPHJkZjpsaSBzdEV2dDph +Y3Rpb249ImRlcml2ZWQiIHN0RXZ0OnBhcmFtZXRlcnM9ImNvbnZlcnRlZCBmcm9tIGFwcGxpY2F0 +aW9uL3ZuZC5hZG9iZS5waG90b3Nob3AgdG8gaW1hZ2UvanBlZyIvPiA8cmRmOmxpIHN0RXZ0OmFj +dGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MjUzYzljMWUtNThhZi00OGFl +LWIzMjQtZjFkNTllYjNkMTIzIiBzdEV2dDp3aGVuPSIyMDE4LTEwLTMwVDE0OjMxOjI2LTA3OjAw +IiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3No +KSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPHhtcE1N +OkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MjdmMjg4YzktZDAxNy00Mjg4 +LTkxMDItYTdiZTliOGYyMzc0IiBzdFJlZjpkb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3No +b3A6NTBkYzQ4MGEtNjQyMC0wNDRlLTllODYtZGYwZjFmMDI3ZjkyIiBzdFJlZjpvcmlnaW5hbERv +Y3VtZW50SUQ9InhtcC5kaWQ6NDdhMTc0N2ItNTg0Ni00ZDZlLTg4OWQtM2Y2YWUyMzViNDA2Ii8+ +IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDw/eHBhY2tldCBlbmQ9Inci +Pz7/4g8kSUNDX1BST0ZJTEUAAQEAAA8UYXBwbAIQAABtbnRyUkdCIFhZWiAH4gAIAB4ACQAIABth +Y3NwQVBQTAAAAABBUFBMAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFkZXNjAAABUAAAAGJkc2NtAAAB +tAAAA/xjcHJ0AAAFsAAAACN3dHB0AAAF1AAAABRyWFlaAAAF6AAAABRnWFlaAAAF/AAAABRiWFla +AAAGEAAAABRyVFJDAAAGJAAACAxhYXJnAAAOMAAAACB2Y2d0AAAOUAAAADBuZGluAAAOgAAAAD5j +aGFkAAAOwAAAACxtbW9kAAAO7AAAAChiVFJDAAAGJAAACAxnVFJDAAAGJAAACAxhYWJnAAAOMAAA +ACBhYWdnAAAOMAAAACBkZXNjAAAAAAAAAAhEaXNwbGF5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +bWx1YwAAAAAAAAAjAAAADGhySFIAAAAUAAABtGtvS1IAAAASAAAByG5iTk8AAAAUAAAB2mlkAAAA +AAASAAAB7mh1SFUAAAAWAAACAGNzQ1oAAAAWAAACFmRhREsAAAASAAACLG5sTkwAAAAeAAACPmZp +RkkAAAAUAAACXGl0SVQAAAAWAAACFnJvUk8AAAAUAAACcGVzRVMAAAAYAAAChGFyAAAAAAAYAAAC +nHVrVUEAAAAWAAACtGhlSUwAAAAOAAACynpoVFcAAAAOAAAC2HZpVk4AAAAYAAAC5nNrU0sAAAAW +AAAC/npoQ04AAAAOAAADFHJ1UlUAAAAWAAADImZyRlIAAAAYAAADOG1zAAAAAAAWAAADUGhpSU4A +AAAWAAADZnRoVEgAAAAMAAADfGNhRVMAAAAYAAAChGVzWEwAAAAYAAAChGRlREUAAAAWAAADiGVu +VVMAAAAWAAADnnB0QlIAAAAWAAACFnBsUEwAAAAWAAACFmVsR1IAAAASAAADtHN2U0UAAAASAAAD +xnRyVFIAAAASAAAD2HB0UFQAAAAWAAACFmphSlAAAAASAAAD6gBWAEcAQQAgAHAAcgBpAGsAYQB6 +AFYARwBBACC1FMKk1Qy4CMd0AFYARwBBAC0AcwBrAGoAZQByAG0ATABhAHkAYQByACAAVgBHAEEA +VgBHAEEAIABrAGkAagBlAGwAegFRAE0AbwBuAGkAdABvAHIAIABWAEcAQQBWAEcAQQAtAHMAawDm +AHIAbQBWAEcAQQAtAGIAZQBlAGwAZABzAGMAaABlAHIAbQBWAEcAQQAtAG4A5AB5AHQAdAD2AEEA +ZgBpAhkAYQBqACAAVgBHAEEAUABhAG4AdABhAGwAbABhACAAVgBHAEEGNAYnBjQGKQAgBjkGMQY2 +ACAAVgBHAEEAVgBHAEEALQQ0BDgEQQQ/BDsENQQ5Bd4F4QXaACAAVgBHAEEAVgBHAEEAIJhveTpW +aABNAOAAbgAgAGgA7ABuAGgAIABWAEcAQQBWAEcAQQAgAGQAaQBzAHAAbABlAGoAVgBHAEEAIGY+ +eTpWaAQcBD4EPQQ4BEIEPgRAACAAVgBHAEEATQBvAG4AaQB0AGUAdQByACAAVgBHAEEAUABhAHAA +YQByAGEAbgAgAFYARwBBAFYARwBBACAARABJAFMAUABMAEEAWQ4IDi0AIABWAEcAQQBWAEcAQQAt +AE0AbwBuAGkAdABvAHIAVgBHAEEAIABEAGkAcwBwAGwAYQB5A58DuAPMA70DtwAgAFYARwBBAFYA +RwBBAC0AcwBrAOQAcgBtAFYARwBBACAARQBrAHIAYQBuAFYARwBBMMcwozC5MNcw7DCkdGV4dAAA +AABDb3B5cmlnaHQgQXBwbGUgSW5jLiwgMjAxOAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAA +AAAAdEsAAD3tAAAD0FhZWiAAAAAAAABacwAArHQAABczWFlaIAAAAAAAACgYAAAVngAAuCpjdXJ2 +AAAAAAAABAAAAAAFAAoADwAUABkAHgAjACgALQAyADYAOwBAAEUASgBPAFQAWQBeAGMAaABtAHIA +dwB8AIEAhgCLAJAAlQCaAJ8AowCoAK0AsgC3ALwAwQDGAMsA0ADVANsA4ADlAOsA8AD2APsBAQEH +AQ0BEwEZAR8BJQErATIBOAE+AUUBTAFSAVkBYAFnAW4BdQF8AYMBiwGSAZoBoQGpAbEBuQHBAckB +0QHZAeEB6QHyAfoCAwIMAhQCHQImAi8COAJBAksCVAJdAmcCcQJ6AoQCjgKYAqICrAK2AsECywLV +AuAC6wL1AwADCwMWAyEDLQM4A0MDTwNaA2YDcgN+A4oDlgOiA64DugPHA9MD4APsA/kEBgQTBCAE +LQQ7BEgEVQRjBHEEfgSMBJoEqAS2BMQE0wThBPAE/gUNBRwFKwU6BUkFWAVnBXcFhgWWBaYFtQXF +BdUF5QX2BgYGFgYnBjcGSAZZBmoGewaMBp0GrwbABtEG4wb1BwcHGQcrBz0HTwdhB3QHhgeZB6wH +vwfSB+UH+AgLCB8IMghGCFoIbgiCCJYIqgi+CNII5wj7CRAJJQk6CU8JZAl5CY8JpAm6Cc8J5Qn7 +ChEKJwo9ClQKagqBCpgKrgrFCtwK8wsLCyILOQtRC2kLgAuYC7ALyAvhC/kMEgwqDEMMXAx1DI4M +pwzADNkM8w0NDSYNQA1aDXQNjg2pDcMN3g34DhMOLg5JDmQOfw6bDrYO0g7uDwkPJQ9BD14Peg+W +D7MPzw/sEAkQJhBDEGEQfhCbELkQ1xD1ERMRMRFPEW0RjBGqEckR6BIHEiYSRRJkEoQSoxLDEuMT +AxMjE0MTYxODE6QTxRPlFAYUJxRJFGoUixStFM4U8BUSFTQVVhV4FZsVvRXgFgMWJhZJFmwWjxay +FtYW+hcdF0EXZReJF64X0hf3GBsYQBhlGIoYrxjVGPoZIBlFGWsZkRm3Gd0aBBoqGlEadxqeGsUa +7BsUGzsbYxuKG7Ib2hwCHCocUhx7HKMczBz1HR4dRx1wHZkdwx3sHhYeQB5qHpQevh7pHxMfPh9p +H5Qfvx/qIBUgQSBsIJggxCDwIRwhSCF1IaEhziH7IiciVSKCIq8i3SMKIzgjZiOUI8Ij8CQfJE0k +fCSrJNolCSU4JWgllyXHJfcmJyZXJocmtyboJxgnSSd6J6sn3CgNKD8ocSiiKNQpBik4KWspnSnQ +KgIqNSpoKpsqzysCKzYraSudK9EsBSw5LG4soizXLQwtQS12Last4S4WLkwugi63Lu4vJC9aL5Ev +xy/+MDUwbDCkMNsxEjFKMYIxujHyMioyYzKbMtQzDTNGM38zuDPxNCs0ZTSeNNg1EzVNNYc1wjX9 +Njc2cjauNuk3JDdgN5w31zgUOFA4jDjIOQU5Qjl/Obw5+To2OnQ6sjrvOy07azuqO+g8JzxlPKQ8 +4z0iPWE9oT3gPiA+YD6gPuA/IT9hP6I/4kAjQGRApkDnQSlBakGsQe5CMEJyQrVC90M6Q31DwEQD +REdEikTORRJFVUWaRd5GIkZnRqtG8Ec1R3tHwEgFSEtIkUjXSR1JY0mpSfBKN0p9SsRLDEtTS5pL +4kwqTHJMuk0CTUpNk03cTiVObk63TwBPSU+TT91QJ1BxULtRBlFQUZtR5lIxUnxSx1MTU19TqlP2 +VEJUj1TbVShVdVXCVg9WXFapVvdXRFeSV+BYL1h9WMtZGllpWbhaB1pWWqZa9VtFW5Vb5Vw1XIZc +1l0nXXhdyV4aXmxevV8PX2Ffs2AFYFdgqmD8YU9homH1YklinGLwY0Njl2PrZEBklGTpZT1lkmXn +Zj1mkmboZz1nk2fpaD9olmjsaUNpmmnxakhqn2r3a09rp2v/bFdsr20IbWBtuW4SbmtuxG8eb3hv +0XArcIZw4HE6cZVx8HJLcqZzAXNdc7h0FHRwdMx1KHWFdeF2Pnabdvh3VnezeBF4bnjMeSp5iXnn +ekZ6pXsEe2N7wnwhfIF84X1BfaF+AX5ifsJ/I3+Ef+WAR4CogQqBa4HNgjCCkoL0g1eDuoQdhICE +44VHhauGDoZyhteHO4efiASIaYjOiTOJmYn+imSKyoswi5aL/IxjjMqNMY2Yjf+OZo7OjzaPnpAG +kG6Q1pE/kaiSEZJ6kuOTTZO2lCCUipT0lV+VyZY0lp+XCpd1l+CYTJi4mSSZkJn8mmia1ZtCm6+c +HJyJnPedZJ3SnkCerp8dn4uf+qBpoNihR6G2oiailqMGo3aj5qRWpMelOKWpphqmi6b9p26n4KhS +qMSpN6mpqhyqj6sCq3Wr6axcrNCtRK24ri2uoa8Wr4uwALB1sOqxYLHWskuywrM4s660JbSctRO1 +irYBtnm28Ldot+C4WbjRuUq5wro7urW7LrunvCG8m70VvY++Cr6Evv+/er/1wHDA7MFnwePCX8Lb +w1jD1MRRxM7FS8XIxkbGw8dBx7/IPci8yTrJuco4yrfLNsu2zDXMtc01zbXONs62zzfPuNA50LrR +PNG+0j/SwdNE08bUSdTL1U7V0dZV1tjXXNfg2GTY6Nls2fHadtr724DcBdyK3RDdlt4c3qLfKd+v +4DbgveFE4cziU+Lb42Pj6+Rz5PzlhOYN5pbnH+ep6DLovOlG6dDqW+rl63Dr++yG7RHtnO4o7rTv +QO/M8Fjw5fFy8f/yjPMZ86f0NPTC9VD13vZt9vv3ivgZ+Kj5OPnH+lf65/t3/Af8mP0p/br+S/7c +/23//3BhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAAE9AAAApbdmNndAAAAAAAAAABAAEAAAAAAAAA +AQAAAAEAAAAAAAAAAQAAAAEAAAAAAAAAAQAAbmRpbgAAAAAAAAA2AAChSAAAVwoAAEuFAACa4QAA +J64AABO2AABQDQAAVDkAAjMzAAIzMwACMzMAAAAAAAAAAHNmMzIAAAAAAAEMQgAABd7///MmAAAH +kwAA/ZD///ui///9owAAA9wAAMBubW1vZAAAAAB1bmtuAAAHFwAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAP/uAA5BZG9iZQBkAAAAAAH/2wCEAAoHBwsICxIKChIWEQ4RFhsXFhYXGyIXFxcXFyIRDAwM +DAwMEQwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwBCw4OHxMfIhgYIhQODg4UFA4ODg4UEQwM +DAwMEREMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDP/AABEIBYwEOAMBEQACEQED +EQH/3QAEAIf/xAGiAAAABwEBAQEBAAAAAAAAAAAEBQMCBgEABwgJCgsBAAICAwEBAQEBAAAAAAAA +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 + +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: text/css +Content-Transfer-Encoding: quoted-printable +Content-Location: https://adfs.slac.stanford.edu/adfs/portal/css/style.css?id=B118A812177E36775C02C5FF0F4B84F3170635DEC0B87D4BA118A77C982491A0 + +@charset "utf-8"; + +* { margin: 0px; padding: 0px; } + +html, body { height: 100%; width: 100%; background-color: rgb(255, 255, 255= +); color: rgb(0, 0, 0); font-weight: normal; font-family: "Segoe UI", Segoe= +, SegoeUI-Regular-final, Tahoma, Helvetica, Arial, sans-serif; min-width: 5= +00px; } + +body { font-size: 0.9em; } + +#noScript { margin: 16px; color: black; } + +:lang(en-GB) { quotes: "=E2=80=98" "=E2=80=99" "=E2=80=9C" "=E2=80=9D"; } + +:lang(zh) { } + +#fullPage, #brandingWrapper { width: 100%; height: 100%; background-color: = +inherit; } + +#brandingWrapper { background-color: rgb(68, 136, 221); } + +#branding { height: 100%; margin-right: 500px; margin-left: 0px; background= +-color: inherit; background-repeat: no-repeat; background-size: cover; } + +#contentWrapper { position: relative; width: 500px; height: 100%; overflow:= + auto; background-color: rgb(255, 255, 255); margin-left: -500px; margin-ri= +ght: 0px; } + +#content { min-height: 100%; margin: 0px auto -55px; padding: 0px 150px 0px= + 50px; height: auto !important; } + +#header { font-size: 2em; font-weight: lighter; font-family: "Segoe UI Ligh= +t", Segoe, SegoeUI-Light-final, Tahoma, Helvetica, Arial, sans-serif; paddi= +ng-top: 90px; margin-bottom: 60px; min-height: 100px; overflow: hidden; } + +#header img { width: auto; height: auto; } + +#workArea, #header { overflow-wrap: break-word; width: 350px; } + +#workArea { margin-bottom: 90px; } + +#footerPlaceholder { height: 40px; } + +#footer { height: 40px; padding: 10px 50px 0px; position: relative; color: = +rgb(102, 102, 102); font-size: 0.78em; } + +#footerLinks { float: none; padding-top: 10px; } + +#copyright { color: rgb(105, 105, 105); display: none; } + +.pageLink { color: rgb(0, 0, 0); padding-left: 16px; } + +.clear { clear: both; } + +.float { float: left; } + +.floatReverse { float: right; } + +.indent { margin-left: 16px; } + +.indentNonCollapsible { padding-left: 16px; } + +.hidden { display: none; } + +.notHidden { display: inherit; } + +.error { color: rgb(200, 83, 5); } + +.actionLink { margin-bottom: 8px; display: block; } + +a { color: rgb(38, 114, 236); text-decoration: none; background-color: tran= +sparent; } + +ul { list-style-type: disc; } + +h1, h2, h3, h4, h5, label { margin-bottom: 8px; } + +.submitMargin { margin-top: 38px; margin-bottom: 30px; } + +.topFieldMargin { margin-top: 8px; } + +.fieldMargin { margin-bottom: 8px; } + +.groupMargin { margin-bottom: 30px; } + +.sectionMargin { margin-bottom: 64px; } + +.block { display: block; } + +.autoWidth { width: auto; } + +.fullWidth { width: 342px; } + +.fullWidthIndent { width: 326px; } + +input { max-width: 100%; font-family: inherit; margin-bottom: 8px; } + +input[type=3D"radio"], input[type=3D"checkbox"] { vertical-align: middle; m= +argin-bottom: 0px; } + +span.submit, input[type=3D"submit"] { border: 1px solid; background-color: = +rgb(38, 114, 236); min-width: 80px; width: auto; height: 30px; padding: 4px= + 20px 6px; color: rgb(255, 255, 255); cursor: pointer; margin-bottom: 8px; = +transition: background; user-select: none; } + +input[type=3D"submit"]:hover, span.submit:hover { background: rgb(212, 227,= + 251); } + +input.text { height: 28px; padding: 0px 3px; border: 1px solid rgb(186, 186= +, 186); } + +input.text:focus { border: 1px solid rgb(107, 107, 107); } + +select { height: 28px; min-width: 60px; max-width: 100%; margin-bottom: 8px= +; white-space: nowrap; overflow: hidden; box-shadow: none; padding: 2px; fo= +nt-family: inherit; } + +h1, .giantText { font-size: 2em; font-weight: lighter; } + +h2, .bigText { font-size: 1.33em; font-weight: lighter; } + +h3, .normalText { font-size: 1em; font-weight: normal; } + +h4, .smallText { font-size: 0.9em; font-weight: normal; } + +h5, .tinyText { font-size: 0.8em; font-weight: normal; } + +.hint { color: rgb(153, 153, 153); } + +.emphasis { font-weight: 700; color: rgb(47, 47, 47); } + +.smallIcon { height: 20px; padding-right: 12px; vertical-align: middle; } + +.largeIcon { height: 48px; vertical-align: middle; } + +.largeTextNoWrap { height: 48px; display: table-cell; vertical-align: middl= +e; white-space: nowrap; font-size: 1.2em; } + +.idp { height: 48px; clear: both; padding: 8px; overflow: hidden; } + +.idp:hover { background-color: rgb(204, 204, 204); } + +.idpDescription { width: 80%; } + +@media only screen and (max-height: 820px) { + #header { padding-top: 40px; min-height: 0px; overflow: hidden; } + #workArea { margin-bottom: 60px; } +} + +@media only screen and (max-height: 500px) { + #header { padding-top: 30px; margin-bottom: 30px; } + #workArea { margin-bottom: 40px; } +} + +@media only screen and (max-width: 600px) { + html, body { min-width: 260px; } + #brandingWrapper { display: none; } + #contentWrapper { float: none; width: 100%; margin: 0px auto; } + #content, #footer, #header { width: 400px; padding-left: 0px; padding-rig= +ht: 0px; margin-left: auto; margin-right: auto; } + #workArea { width: 100%; } + .fullWidth { width: 392px; } + .fullWidthIndent { width: 376px; } +} + +@media only screen and (max-width: 450px) { + body { font-size: 0.8em; } + #content, #footer { width: auto; margin-right: 33px; margin-left: 25px; } + #header { width: auto; } + span.submit, input[type=3D"submit"] { font-size: 0.9em; } + .fullWidth { width: 100%; margin-left: auto; margin-right: auto; } + .fullWidthIndent { width: 85%; } + .idpDescription { width: 70%; } +} + +@media only screen and (max-width: 280px) { + #contentWrapper { width: 260px; } + .idpDescription { max-width: 160px; min-width: 100px; } +} + +html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, b= +lockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, i= +mg, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i= +, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, cap= +tion, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, emb= +ed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, se= +ction, summary, time, mark, audio, video { margin: 0px; padding: 0px; borde= +r: 0px; font-style: inherit; font-variant: inherit; font-weight: inherit; f= +ont-stretch: inherit; line-height: inherit; font-family: inherit; font-opti= +cal-sizing: inherit; font-size-adjust: inherit; font-kerning: inherit; font= +-feature-settings: inherit; font-variation-settings: inherit; font-size: 10= +0%; vertical-align: baseline; outline: none 0px; } + +audio, canvas, video { display: inline-block; } + +ol, ul { list-style: none; } + +table { border-collapse: collapse; border-spacing: 0px; } + +caption, th, td { text-align: left; font-weight: normal; vertical-align: mi= +ddle; } + +q, blockquote { quotes: none; } + +q::before, q::after, blockquote::before, blockquote::after { content: none;= + } + +a img { border: none; } + +article, aside, details, figcaption, figure, footer, header, hgroup, menu, = +nav, section, summary { display: block; } + +img { height: auto; } + +img, object, embed { max-width: 100%; } + +a[href*=3D"mailto:"] { word-break: break-all; } + +.clearfix, .general-left-title .sub-header, ul.tabs, .action-links, .pane-s= +ystem-user-menu ul, .social-media-content, .share-block-wrapper, .footer-fi= +rst ul.menu, .social-icon-block, .pager, article.node-blog.node-teaser .lin= +ks, .profile-info-full .views-row-2, .views-field-field-prf-bio-education, = +.views-field-field-prf-bio-pexp, .views-field-field-prf-bio-ar, .views-fiel= +d-field-prf-bio-ha, .views-field-field-prf-bio-pa, .views-field-field-prf-b= +io-pub, .views-field-field-prf-bio-tp, .views-responsive-grid.views-columns= +-2 .views-row, .event-listing-item, .wiki-filter ul, .blog-tags ul, .connec= +t-with-me .views-row, ul.blog-archive-class > li ul, div .color-box, .icons= + { } + +.clearfix::before, .general-left-title .sub-header::before, ul.tabs::before= +, .action-links::before, .pane-system-user-menu ul::before, .social-media-c= +ontent::before, .share-block-wrapper::before, .footer-first ul.menu::before= +, .social-icon-block::before, .pager::before, article.node-blog.node-teaser= + .links::before, .profile-info-full .views-row-2::before, .views-field-fiel= +d-prf-bio-education::before, .views-field-field-prf-bio-pexp::before, .view= +s-field-field-prf-bio-ar::before, .views-field-field-prf-bio-ha::before, .v= +iews-field-field-prf-bio-pa::before, .views-field-field-prf-bio-pub::before= +, .views-field-field-prf-bio-tp::before, .views-responsive-grid.views-colum= +ns-2 .views-row::before, .event-listing-item::before, .wiki-filter ul::befo= +re, .blog-tags ul::before, .connect-with-me .views-row::before, ul.blog-arc= +hive-class > li ul::before, div .color-box::before, .icons::before, .clearf= +ix::after, .general-left-title .sub-header::after, ul.tabs::after, .action-= +links::after, .pane-system-user-menu ul::after, .social-media-content::afte= +r, .share-block-wrapper::after, .footer-first ul.menu::after, .social-icon-= +block::after, .pager::after, article.node-blog.node-teaser .links::after, .= +profile-info-full .views-row-2::after, .views-field-field-prf-bio-education= +::after, .views-field-field-prf-bio-pexp::after, .views-field-field-prf-bio= +-ar::after, .views-field-field-prf-bio-ha::after, .views-field-field-prf-bi= +o-pa::after, .views-field-field-prf-bio-pub::after, .views-field-field-prf-= +bio-tp::after, .views-responsive-grid.views-columns-2 .views-row::after, .e= +vent-listing-item::after, .wiki-filter ul::after, .blog-tags ul::after, .co= +nnect-with-me .views-row::after, ul.blog-archive-class > li ul::after, div = +.color-box::after, .icons::after { content: ""; display: table; } + +.clearfix::after, .general-left-title .sub-header::after, ul.tabs::after, .= +action-links::after, .pane-system-user-menu ul::after, .social-media-conten= +t::after, .share-block-wrapper::after, .footer-first ul.menu::after, .socia= +l-icon-block::after, .pager::after, article.node-blog.node-teaser .links::a= +fter, .profile-info-full .views-row-2::after, .views-field-field-prf-bio-ed= +ucation::after, .views-field-field-prf-bio-pexp::after, .views-field-field-= +prf-bio-ar::after, .views-field-field-prf-bio-ha::after, .views-field-field= +-prf-bio-pa::after, .views-field-field-prf-bio-pub::after, .views-field-fie= +ld-prf-bio-tp::after, .views-responsive-grid.views-columns-2 .views-row::af= +ter, .event-listing-item::after, .wiki-filter ul::after, .blog-tags ul::aft= +er, .connect-with-me .views-row::after, ul.blog-archive-class > li ul::afte= +r, div .color-box::after, .icons::after { clear: both; } + +.slac-font, .sf-accordion-toggle a::before, .sf-accordion a.sf-with-ul::aft= +er, .sf-accordion li.sf-expanded a.sf-with-ul::after, .mob-icon::before, .u= +ser-icons a::before, .share-block-wrapper a::before, .social-icon-block > d= +iv a::after, .footer-seccond ul li a::after, .event-calendar-page .pager li= + a::before, .calendar-page .view-header ul li.first a::after, .calendar-pag= +e .view-header ul li.first a::before, .calendar-page .view-header ul li.las= +t a::after, .calendar-page .view-header ul li.last a::before, article.node-= +blog.node-teaser header h2 a::after, .profile-info-brief .views-more-link::= +after, .profile-info-full .profile-maxlist-more a::before, .more-link-templ= +ate a::after, .last-three-news .more-link a::after, .event-calendar-block .= +more-link a::after, .event-block a.icon::after, .add-to-calendar a::before,= + .lightbox-download-link::after, .connect-with-me .views-row > div a::after= +, .pane-bundle-slideshow-description-bottom .field-slideshow-controls a::af= +ter, .pane-bundle-slac-mini-slideshow .field-slideshow-controls a::after { = +font-family: slac; font-weight: normal; font-style: normal; text-indent: 0p= +x; } + +.date-and-author, .faq-comments header, .pane-node-comments header, .news-l= +anding time, .news-landing .field-name-field-slac-news-date, .kb-articles t= +ime, .kb-articles .field-name-field-slac-news-date, .wiki-search .submitted= + { color: rgb(85, 85, 85); font-size: 0.75rem; display: block; margin-botto= +m: 7px; font-style: italic; } + +.basic-format-text, article.node-blog.view-mode-full .field-type-text-with-= +summary, article.node-blog.view-mode-full .blog-wrapper, .article_panel_lay= +out .pane-node-body, .service-body, .node-slac-sc-catalog-item, .node-suppo= +rt-ticket .field-type-text-with-summary { font-size: 0.875rem; color: rgb(7= +1, 71, 71); } + +.basic-format-text h2, article.node-blog.view-mode-full .field-type-text-wi= +th-summary h2, article.node-blog.view-mode-full .blog-wrapper h2, .article_= +panel_layout .pane-node-body h2, .service-body h2, .node-slac-sc-catalog-it= +em h2, .node-support-ticket .field-type-text-with-summary h2 { font-size: 1= +rem; margin-bottom: 10px; margin-top: 15px; } + +.basic-format-text h3, article.node-blog.view-mode-full .field-type-text-wi= +th-summary h3, article.node-blog.view-mode-full .blog-wrapper h3, .article_= +panel_layout .pane-node-body h3, .service-body h3, .node-slac-sc-catalog-it= +em h3, .node-support-ticket .field-type-text-with-summary h3 { font-size: 0= +.875rem; font-weight: bold; } + +.basic-format-text p, article.node-blog.view-mode-full .field-type-text-wit= +h-summary p, article.node-blog.view-mode-full .blog-wrapper p, .article_pan= +el_layout .pane-node-body p, .service-body p, .node-slac-sc-catalog-item p,= + .node-support-ticket .field-type-text-with-summary p { margin-bottom: 15px= +; line-height: 20px; } + +.basic-format-text p img, article.node-blog.view-mode-full .field-type-text= +-with-summary p img, article.node-blog.view-mode-full .blog-wrapper p img, = +.article_panel_layout .pane-node-body p img, .service-body p img, .node-sla= +c-sc-catalog-item p img, .node-support-ticket .field-type-text-with-summary= + p img { margin: 15px 0px; } + +.basic-format-text ul, article.node-blog.view-mode-full .field-type-text-wi= +th-summary ul, article.node-blog.view-mode-full .blog-wrapper ul, .article_= +panel_layout .pane-node-body ul, .service-body ul, .node-slac-sc-catalog-it= +em ul, .node-support-ticket .field-type-text-with-summary ul, .basic-format= +-text ol, article.node-blog.view-mode-full .field-type-text-with-summary ol= +, article.node-blog.view-mode-full .blog-wrapper ol, .article_panel_layout = +.pane-node-body ol, .service-body ol, .node-slac-sc-catalog-item ol, .node-= +support-ticket .field-type-text-with-summary ol { list-style-type: disc; pa= +dding-left: 20px; margin-bottom: 10px; } + +.basic-format-text ul.links, article.node-blog.view-mode-full .field-type-t= +ext-with-summary ul.links, article.node-blog.view-mode-full .blog-wrapper u= +l.links, .article_panel_layout .pane-node-body ul.links, .service-body ul.l= +inks, .node-slac-sc-catalog-item ul.links, .node-support-ticket .field-type= +-text-with-summary ul.links, .basic-format-text ol.links, article.node-blog= +.view-mode-full .field-type-text-with-summary ol.links, article.node-blog.v= +iew-mode-full .blog-wrapper ol.links, .article_panel_layout .pane-node-body= + ol.links, .service-body ol.links, .node-slac-sc-catalog-item ol.links, .no= +de-support-ticket .field-type-text-with-summary ol.links { padding-left: 0p= +x; } + +.basic-format-text ul li, article.node-blog.view-mode-full .field-type-text= +-with-summary ul li, article.node-blog.view-mode-full .blog-wrapper ul li, = +.article_panel_layout .pane-node-body ul li, .service-body ul li, .node-sla= +c-sc-catalog-item ul li, .node-support-ticket .field-type-text-with-summary= + ul li, .basic-format-text ol li, article.node-blog.view-mode-full .field-t= +ype-text-with-summary ol li, article.node-blog.view-mode-full .blog-wrapper= + ol li, .article_panel_layout .pane-node-body ol li, .service-body ol li, .= +node-slac-sc-catalog-item ol li, .node-support-ticket .field-type-text-with= +-summary ol li { line-height: 20px; margin-bottom: 5px; } + +.basic-format-text ol, article.node-blog.view-mode-full .field-type-text-wi= +th-summary ol, article.node-blog.view-mode-full .blog-wrapper ol, .article_= +panel_layout .pane-node-body ol, .service-body ol, .node-slac-sc-catalog-it= +em ol, .node-support-ticket .field-type-text-with-summary ol { list-style-t= +ype: decimal; } + +.basic-intro-text-format, .service-overview, .service-description { margin-= +bottom: 15px; } + +@font-face { font-family: slac; src: url("../fonts/slac.woff") format("woff= +"), url("../fonts/slac.ttf") format("truetype"); font-weight: normal; font-= +style: normal; } + +[class^=3D"icon-"], [class*=3D" icon-"] { font-family: slac; speak: none; f= +ont-style: normal; font-weight: normal; font-variant: normal; text-transfor= +m: none; line-height: 1; } + +.icon-ytb::before { content: "=EE=98=80"; } + +.icon-unk::before { content: "=EE=98=81"; } + +.icon-gp::before { content: "=EE=98=83"; } + +.icon-fb::before { content: "=EE=98=84"; } + +.icon-ac::before { content: "=EE=98=85"; } + +.icon-tw::before { content: "=EE=98=90"; } + +.icon-arr::before { content: "=EE=98=88"; } + +.icon-arr2::before { content: "=EE=98=95"; } + +.icon-arr::before { content: "=EE=98=96"; } + +.icon-angle-down::before { content: "=EE=98=86"; } + +.icon-angle-up::before { content: "=EE=98=87"; } + +.icon-angle-right::before { content: "=3D"; } + +.icon-angle-left::before { content: "=EE=98=89"; } + +.icon-double-angle-down::before { content: "=EE=98=8A"; } + +.icon-double-angle-up::before { content: "=EE=98=8B"; } + +.icon-double-angle-right::before { content: "=EE=98=8C"; } + +.icon-double-angle-left::before { content: "=EE=98=8D"; } + +.icon-linkedin-sign::before { content: "=EE=98=82"; } + +.icon-signout::before { content: "=EE=98=93"; } + +.icon-reorder::before { content: "=EE=98=91"; } + +.icon-calendar::before { content: "=EE=98=9F"; } + +.icon-list::before { content: "=EE=98=8E"; } + +.icon-logout::before { content: "=EE=98=94"; } + +.icon-key::before { content: "=EE=98=9B"; } + +.icon-arrow-left::before { content: "=EE=98=97"; } + +.icon-arrow-down::before { content: "=EE=98=9C"; } + +.icon-arrow-up::before { content: "=EE=98=9D"; } + +.icon-arrow-right::before { content: "=EE=98=9E"; } + +.icon-list2::before { content: "=EE=98=8F"; } + +.icon-key2::before { content: "=EE=98=98"; } + +.icon-user::before { content: "=EE=98=92"; } + +.icon-key::before { content: "=EE=98=99"; } + +.icon-locked::before { content: "=EE=98=9A"; } + +* { box-sizing: border-box; } + +.sprites-sprite, .sprites-Microsoft-Office-Excel-icon, span.document-type-m= +s-office-excel, .sprites-Microsoft-Office-Word-icon, span.document-type-ms-= +office-word, .sprites-Microsoft-PowerPoint-icon, span.document-type-ms-offi= +ce-powerpoint, .sprites-compressed-icon, span.document-type-compressed, .sp= +rites-external-icon, span.document-type-external, .sprites-google-docs-icon= +, span.document-type-gdoc, .sprites-keynote-on-icon, span.document-type-key= +note, .sprites-pdf_icon_16px, span.document-type-pdf { background: url("../= +images/sprites-s7e31974e21.png") no-repeat; } + +.sprites-Microsoft-Office-Excel-icon, span.document-type-ms-office-excel { = +background-position: 0px -36px; height: 16px; width: 16px; } + +.sprites-Microsoft-Office-Word-icon, span.document-type-ms-office-word { ba= +ckground-position: 0px 0px; height: 16px; width: 16px; } + +.sprites-Microsoft-PowerPoint-icon, span.document-type-ms-office-powerpoint= + { background-position: 0px -180px; height: 16px; width: 16px; } + +.sprites-compressed-icon, span.document-type-compressed { background-positi= +on: 0px -216px; height: 16px; width: 16px; } + +.sprites-external-icon, span.document-type-external { background-position: = +0px -252px; height: 11px; width: 12px; } + +.sprites-google-docs-icon, span.document-type-gdoc { background-position: 0= +px -108px; height: 16px; width: 16px; } + +.sprites-keynote-on-icon, span.document-type-keynote { background-position:= + 0px -72px; height: 16px; width: 16px; } + +.sprites-pdf_icon_16px, span.document-type-pdf { background-position: 0px -= +144px; height: 16px; width: 16px; } + +.main-menu { background-color: rgb(235, 235, 223); padding: 8px 0px; text-t= +ransform: uppercase; } + +@media (min-width: 690px) { + .main-menu { padding: 9px 0px 0px; } +} + +.sf-accordion-toggle span { display: none; } + +.sf-accordion-toggle a { background-color: transparent; border-radius: 3px;= + padding: 1px 6px; display: inline-block; } + +.sf-accordion-toggle a:hover { box-shadow: rgb(135, 22, 40) 0px 0px 1px 1px= +; } + +.sf-accordion-toggle a::before { content: "=EE=98=91"; color: rgb(135, 22, = +40); font-size: 1.5625rem; position: relative; display: inline-block; } + +.sf-accordion-toggle a.sf-expanded { background-color: rgb(135, 22, 40); bo= +x-shadow: rgb(135, 22, 40) 0px 0px 1px 1px; } + +.sf-accordion-toggle a.sf-expanded::before { color: white; } + +.page-basic-io .main-menu { background-color: rgb(234, 238, 240); } + +@media screen and (min-width: 690px) { + .page-basic-io .main-menu li { margin: 0px; } +} + +.page-basic-io .main-menu ul.sf-menu.sf-expanded a { color: rgb(12, 87, 145= +); } + +.page-basic-io .main-menu ul.sf-menu li a, .page-basic-io .main-menu ul.sf-= +menu li.sfHover a { color: rgb(12, 87, 145) !important; } + +.page-basic-io .main-menu ul.sf-menu li a:hover, .page-basic-io .main-menu = +ul.sf-menu li.sfHover a:hover { color: rgb(12, 87, 145); } + +.page-basic-io .main-menu ul.sf-menu li.menuparent > ul, .page-basic-io .ma= +in-menu ul.sf-menu li.sfHover.menuparent > ul { background-color: rgb(234, = +238, 240); } + +.page-basic-io .main-menu ul.sf-menu li.menuparent > ul a, .page-basic-io .= +main-menu ul.sf-menu li.sfHover.menuparent > ul a { color: rgb(12, 87, 145)= +; } + +.page-basic-io .main-menu ul.sf-menu li.menuparent > ul a:hover, .page-basi= +c-io .main-menu ul.sf-menu li.sfHover.menuparent > ul a:hover { background:= + rgb(211, 224, 226); } + +.page-basic-io .logo-container { width: 70%; } + +@media (min-width: 690px) { + .page-basic-io .logo-container { width: 37.8705%; } +} + +.page-basic-io .user-search { width: 25%; } + +@media (min-width: 690px) { + .page-basic-io .user-search { width: 57.8705%; } +} + +@media (max-width: 690px) { + .page-basic-io .mobile-block { min-height: 92px; } +} + +.page-basic-io #slac-search-wrapper { width: 355px; } + +@media (max-width: 690px) { + .page-basic-io #slac-search-wrapper { width: 100%; } +} + +.page-basic-io #slac-search-options { float: left; width: 125px; height: 24= +px; border: 1px solid rgb(225, 225, 226); } + +@media (max-width: 690px) { + .page-basic-io #slac-search-options { float: none; width: 100%; margin: 1= +0px 0px 0px; height: 28px; } +} + +.page-basic-io #slac-search-options > div { display: block; position: relat= +ive; background-color: white; border: none; } + +.page-basic-io #slac-search-options > div > div { padding: 2px 6px 5px; } + +@media (min-width: 690px) { + .page-basic-io #slac-search-options > div > div { padding: 0px; } +} + +.page-basic-io #slac-search-options select { display: block; width: 100%; h= +eight: 22px; line-height: 22px; padding: 0px 0px 0px 5px; border: none; box= +-shadow: none; font-size: 10px; color: rgb(145, 145, 145); text-transform: = +uppercase; font-weight: bold; } + +.page-basic-io #slac-search { width: 190px; float: left; margin-left: 2px; = +} + +@media (max-width: 690px) { + .page-basic-io #slac-search { width: 85%; } +} + +.page-basic-io .header.with_user_search form input[type=3D"text"] { height:= + 24px; border: 1px solid rgb(225, 225, 226); margin: 0px; box-shadow: none;= + line-height: 21px; color: rgb(145, 145, 145); letter-spacing: 1px; font-si= +ze: 10px; font-weight: bold; float: left; } + +.page-basic-io .header.with_user_search form input[type=3D"text"]::-webkit-= +input-placeholder { color: rgb(145, 145, 145); font-size: 10px; font-weight= +: bold; text-transform: uppercase; } + +@media (max-width: 690px) { + .page-basic-io .header.with_user_search form input[type=3D"text"] { heigh= +t: 28px; } +} + +.page-basic-io .header.with_user_search form input[type=3D"submit"] { backg= +round: rgb(32, 132, 195); border: none; color: white; text-transform: upper= +case; width: 34px; height: 22px; box-shadow: none; position: relative; } + +@media (max-width: 690px) { + .page-basic-io .header.with_user_search form input[type=3D"submit"] { wid= +th: 12%; margin-left: 2%; height: 26px; top: 1px; } +} + +@media (max-width: 690px) { + .page-basic-io .header.with_user_search form { padding-right: 0px; } +} + +.page-basic-io .sf-accordion-toggle a:hover { box-shadow: rgb(12, 87, 145) = +0px 0px 1px 1px; } + +.page-basic-io .sf-accordion-toggle a::before { color: rgb(12, 87, 145); } + +.page-basic-io .sf-accordion-toggle a.sf-expanded { background-color: rgb(1= +2, 87, 145); box-shadow: rgb(12, 87, 145) 0px 0px 1px 1px; } + +.page-basic-io .sf-accordion-toggle a.sf-expanded::before { color: white; } + +@media (min-width: 690px) { + .page-basic-io .main-menu ul.sf-menu li a::before, .page-basic-io .main-m= +enu ul.sf-menu li a::after, .page-basic-io .main-menu ul.sf-menu li a:hover= +::before, .page-basic-io .main-menu ul.sf-menu li a:hover::after, .page-bas= +ic-io .main-menu ul.sf-menu li.sfHover a::before, .page-basic-io .main-menu= + ul.sf-menu li.sfHover a::after, .page-basic-io .main-menu ul.sf-menu li.sf= +Hover a:hover::before, .page-basic-io .main-menu ul.sf-menu li.sfHover a:ho= +ver::after { box-shadow: rgb(32, 132, 195) 0px -2px 0px 0px inset; } +} + +@media (min-width: 691px) and (max-width: 893px) { + .page-basic-io .main-menu.long-menu { background-color: transparent; } + .page-basic-io .main-menu.long-menu ul > li, .page-basic-io .main-menu.lo= +ng-menu ul > li.sfHover { background-color: rgb(234, 238, 240); } + .page-basic-io .main-menu.long-menu ul > li:hover, .page-basic-io .main-m= +enu.long-menu ul > li.sfHover:hover { background: rgb(211, 224, 226); } +} + +@media (min-width: 894px) { + .page-basic-io .main-menu.long-menu ul > li, .page-basic-io .main-menu.lo= +ng-menu ul > li.sfHover { margin-left: 0px !important; margin-right: 0px !i= +mportant; } +} + +.external-organisation .page-basic-io .user-search .pane-search-block { wid= +th: 365px; } + +@media (min-width: 690px) { + .sf-accordion-toggle { display: none; } + .sf-main-menu.sf-horizontal { display: block !important; } + .sf-main-menu.sf-horizontal > li.menuparent { position: relative; } + .sf-main-menu.sf-horizontal > li.menuparent > ul { background-color: rgb(= +235, 235, 223); border-style: solid; position: absolute; text-transform: no= +ne; top: 100%; left: 0px; z-index: 100; color: rgb(135, 22, 40); padding: 6= +px 0px; } + .sf-main-menu.sf-horizontal > li.menuparent > ul.sf-hidden { height: 100%= + !important; opacity: 1 !important; display: none !important; } + .sf-main-menu.sf-horizontal > li.menuparent > ul > li { margin: 0px; floa= +t: none; } + .sf-main-menu.sf-horizontal > li.menuparent > ul > li a { color: inherit;= + padding: 5px 15px; } + .sf-main-menu.sf-horizontal > li.menuparent > a { position: relative; } + .sf-main-menu.sf-horizontal > li.menuparent.active-trail > ul { margin-to= +p: 0px; } + .sf-main-menu.sf-horizontal > li > a:hover, .sf-main-menu.sf-horizontal >= + li.sfHover > a { position: relative; color: rgb(135, 22, 40); } + .sf-main-menu.sf-horizontal > li > a:hover::after, .sf-main-menu.sf-horiz= +ontal > li > a:hover::before, .sf-main-menu.sf-horizontal > li.sfHover > a:= +:after, .sf-main-menu.sf-horizontal > li.sfHover > a::before { content: "";= + box-shadow: rgb(135, 22, 40) 0px -2px 0px 0px inset; position: absolute; b= +ottom: 0px; width: 100%; height: 2px; } + .sf-main-menu.sf-horizontal > li > a:hover::after, .sf-main-menu.sf-horiz= +ontal > li.sfHover > a::after { left: -2px; } + .sf-main-menu.sf-horizontal > li > a:hover::before, .sf-main-menu.sf-hori= +zontal > li.sfHover > a::before { right: -2px; } + .sf-main-menu.sf-horizontal .sf-sub-indicator { display: none; } + .sf-main-menu.sf-horizontal .sf-depth-2 ul { display: none !important; } +} + +ul.sf-menu.sf-accordion { display: none; position: absolute; } + +ul.sf-menu.sf-accordion, ul.sf-menu.sf-accordion ul, ul.sf-menu.sf-accordio= +n li { float: left; width: 100%; } + +ul.sf-menu.sf-accordion ul { margin: 0px; padding: 0px; } + +ul.sf-menu.sf-accordion.sf-expanded, ul.sf-menu.sf-accordion li.sf-expanded= + > ul { position: relative; left: auto !important; top: auto !important; } + +.sf-hidden { position: absolute; left: -99999em !important; top: -99999em != +important; } + +.sf-accordion a.sf-with-ul::after { content: "=EE=98=9C"; margin-left: 5px;= + font-size: 1rem; position: absolute; top: 1px; right: -20px; } + +.sf-accordion li.sf-expanded a.sf-with-ul::after { content: "=EE=98=9D"; } + +ul.sf-menu.sf-accordion.sf-expanded { padding: 15px 0px 5px 21px; text-tran= +sform: none; color: rgb(135, 22, 40); } + +ul.sf-menu.sf-accordion.sf-expanded a.active-trail, ul.sf-menu.sf-accordion= +.sf-expanded a.active { text-decoration: underline; } + +ul.sf-menu.sf-accordion.sf-expanded li { margin-bottom: 16px; } + +ul.sf-menu.sf-accordion.sf-expanded li a { color: inherit; position: relati= +ve; } + +ul.sf-menu.sf-accordion.sf-expanded li.active-trail > a { text-decoration: = +underline; } + +ul.sf-menu.sf-accordion.sf-expanded > li.menuparent { margin-bottom: 14px; = +} + +ul.sf-menu.sf-accordion.sf-expanded > li > ul { padding-left: 15px; padding= +-top: 5px; font-size: 0.875rem; } + +ul.sf-menu.sf-accordion.sf-expanded > li > ul::before, ul.sf-menu.sf-accord= +ion.sf-expanded > li > ul::after { content: ""; display: table; } + +ul.sf-menu.sf-accordion.sf-expanded > li > ul::after { clear: both; } + +ul.sf-menu.sf-accordion.sf-expanded > li > ul > li { margin-bottom: 6px; } + +ul.sf-menu.sf-accordion.sf-expanded > li > ul a::after { display: none; } + +@media (min-width: 690px) { + ul.sf-menu.sf-accordion { display: none !important; } + .main-menu ul { color: rgb(76, 76, 76); } + .main-menu ul::before, .main-menu ul::after { content: ""; display: table= +; } + .main-menu ul::after { clear: both; } + .main-menu ul a { color: rgb(76, 76, 76); } + .main-menu ul li { float: left; margin: 0px 25px; font-size: 0.875rem; } + .main-menu ul li:first-child { margin-left: 0px; } + .main-menu ul li:last-child { margin-right: 0px !important; } + .main-menu ul li a { display: block; padding-bottom: 6px; } + .main-menu ul li a.active-trail, .main-menu ul li a.active { position: re= +lative; color: rgb(135, 22, 40); } + .main-menu ul li a.active-trail::after, .main-menu ul li a.active-trail::= +before, .main-menu ul li a.active::after, .main-menu ul li a.active::before= + { content: ""; box-shadow: rgb(135, 22, 40) 0px -2px 0px 0px inset; positi= +on: absolute; bottom: 0px; width: 100%; height: 2px; } + .main-menu ul li a.active-trail::after, .main-menu ul li a.active::after = +{ left: -2px; } + .main-menu ul li a.active-trail::before, .main-menu ul li a.active::befor= +e { right: -2px; } + .main-menu ul li ul .active-trail a { display: block; } + .main-menu ul li ul .active-trail a::before, .main-menu ul li ul .active-= +trail a::after { display: none; } + .main-menu ul li ul li a:hover { background-color: rgb(219, 219, 198); te= +xt-decoration: underline; } + .sf-menu > li.active-trail > a { position: relative; color: rgb(135, 22, = +40); } + .sf-menu > li.active-trail > a::after, .sf-menu > li.active-trail > a::be= +fore { content: ""; box-shadow: rgb(135, 22, 40) 0px -2px 0px 0px inset; po= +sition: absolute; bottom: 0px; width: 100%; height: 2px; } + .sf-menu > li.active-trail > a::after { left: -2px; } + .sf-menu > li.active-trail > a::before { right: -2px; } +} + +@media (min-width: 691px) and (max-width: 893px) { + .main-menu.long-menu { background-color: transparent; } + .main-menu.long-menu ul > li { width: 25%; margin: 0px; border-bottom: 1p= +x solid white; border-right: 1px solid white; background-color: rgb(235, 23= +5, 223); } + .main-menu.long-menu ul > li:hover { background-color: rgb(243, 241, 235)= +; } + .main-menu.long-menu ul > li:nth-child(4n+4) { border-right: 0px; } + .main-menu.long-menu ul > li > a { padding: 5px 10px; } + .main-menu.long-menu ul > li > a.active-trail, .main-menu.long-menu ul > = +li > a.active { color: rgb(135, 22, 40); border-bottom: 0px none; } + .main-menu.long-menu ul > li > a::after, .main-menu.long-menu ul > li > a= +::before { left: 0px !important; right: 0px !important; } + .main-menu.long-menu ul > li.active-trail a::after, .main-menu.long-menu = +ul > li.active-trail a::before { left: 0px; right: 0px; } + .main-menu.long-menu ul.sf-main-menu.sf-horizontal > li.menuparent > ul {= + width: 100% !important; } + .main-menu.long-menu ul.sf-main-menu.sf-horizontal > li.menuparent > ul >= + li { width: 100%; border: 0px none; background: transparent; } + .main-menu.long-menu ul.sf-main-menu.sf-horizontal > li.menuparent > ul >= + li a { padding: 7px 10px; } + .main-menu.long-menu ul.sf-main-menu.sf-horizontal > li.menuparent > ul >= + li:hover { background-color: transparent; } + .main-menu.long-menu ul.sf-main-menu.sf-horizontal .sfHover { background-= +color: rgb(243, 241, 235); } + .main-menu.long-menu ul.sf-main-menu.sf-horizontal .sfHover > ul { backgr= +ound-color: rgb(243, 241, 235); } +} + +@media (min-width: 894px) { + .sf-main-menu.sf-horizontal > li.menuparent > ul { left: -10px; } + .sf-main-menu.sf-horizontal.long-menu ul li { margin: 0px 25px; } +} + +@media (min-width: 1200px) { + .main-menu.long-menu ul li { margin: 0px 33px; } + .main-menu.long-menu ul li:first-child { margin-left: 17px; } +} + +.content .general-two-col:not(.reverse) .general-left ul, .content .general= +-two-col:not(.reverse) .general-left ol, .content .general-two-col.reverse = +.general-right ul, .content .general-two-col.reverse .general-right ol { li= +st-style-type: disc; padding-left: 20px; margin-bottom: 10px; } + +.content .general-two-col:not(.reverse) .general-left ul li, .content .gene= +ral-two-col:not(.reverse) .general-left ol li, .content .general-two-col.re= +verse .general-right ul li, .content .general-two-col.reverse .general-righ= +t ol li { line-height: 20px; margin-bottom: 5px; } + +.content .general-two-col:not(.reverse) .general-left ol, .content .general= +-two-col.reverse .general-right ol { list-style-type: decimal; } + +.extentions_normal-link, .content .general-left a, .content .general-one-co= +l a, .content .general-right a, .pane-node-field-slac-event-related-links a= + { color: rgb(135, 22, 40); } + +.extentions_normal-link:hover, .content .general-left a:hover, .content .ge= +neral-one-col a:hover, .content .general-right a:hover, .pane-node-field-sl= +ac-event-related-links a:hover { text-decoration: underline; } + +.content .extentions_normal-link_a a { color: rgb(112, 97, 97); } + +.content .extentions_normal-link_a a:hover { text-decoration: underline; co= +lor: rgb(135, 22, 40); } + +.extentions_people_link_sidebar:link, .profile-info-full .profile-textforma= +tter-list a:link { color: rgb(99, 92, 44); text-decoration: none; } + +.extentions_people_link_sidebar:visited, .profile-info-full .profile-textfo= +rmatter-list a:visited { color: rgb(99, 92, 44); } + +.extentions_people_link_sidebar:hover, .profile-info-full .profile-textform= +atter-list a:hover { color: rgb(120, 51, 51); text-decoration: underline; } + +.extentions_people_link_sidebar:active, .profile-info-full .profile-textfor= +matter-list a:active { color: rgb(155, 98, 47); text-decoration: underline;= + } + +a { color: rgb(34, 34, 34); text-decoration: none; } + +a:hover { color: rgb(135, 22, 40); } + +.content .general-right .panel-pane ul li a.active, .content .general-right= + .panel-pane ul li a.active-item { text-decoration: underline; } + +.blog-archive-class a.active, .news-archive-class a.active, .blog-archive-c= +lass a.active-item, .news-archive-class a.active-item { font-weight: bold; = +text-decoration: none !important; } + +.content .general-right .panel-pane ul li { margin-bottom: 5px; } + +h1, h2, h3 { font-weight: bold; } + +h1 { font-size: 1.75rem; } + +h2 { font-size: 1.75rem; } + +.general-left-title h2 { font-size: 1.25rem; } + +.general-left-title h2.pane-title { font-size: 1.75rem; } + +.social-media-content h2 { display: none; } + +.general-left .pane-bundle-share-block h2, .general-left-title .pane-bundle= +-share-block h2 { display: none; } + +.general-content h2 { display: none; } + +.node-webform h2 { margin-bottom: 25px; } + +h2.faq-details-title, h2.faq-answer-title { font-size: 1rem; color: rgb(98,= + 98, 98); margin-bottom: 5px; } + +h3 { font-size: 1.125rem; } + +.event-listing-item h3 { color: rgb(135, 22, 40); } + +.event-listing-item h3 a { color: inherit; } + +html { font-family: Arial, Helvetica, sans-serif; line-height: 1.25; -webki= +t-font-smoothing: antialiased; color: rgb(34, 34, 34); background-color: wh= +ite; } + +.page-basic-io .top-wrapper > .header .inside, .page-basic-io .top-wrapper = +> .top-menu .inside { max-width: 1174px; padding-left: 13px; padding-right:= + 13px; margin-left: auto; margin-right: auto; box-sizing: content-box; } + +.page-basic-io .top-wrapper > .header .inside::after, .page-basic-io .top-w= +rapper > .top-menu .inside::after { content: ""; display: table; clear: bot= +h; } + +.page-basic > .content, .page-basic > .header, .page-basic > .header-menu, = +.page-basic > .top-menu, .page-basic > .main-menu, .page-basic > .site-titl= +e, .page-basic > .footer-first, .page-basic > .footer-seccond { } + +.page-basic > .content > .inside, .page-basic > .header > .inside, .page-ba= +sic > .header-menu > .inside, .page-basic > .top-menu > .inside, .page-basi= +c > .main-menu > .inside, .page-basic > .site-title > .inside, .page-basic = +> .footer-first > .inside, .page-basic > .footer-seccond > .inside { max-wi= +dth: 1174px; padding-left: 13px; padding-right: 13px; margin-left: auto; ma= +rgin-right: auto; box-sizing: content-box; } + +.page-basic > .content > .inside::after, .page-basic > .header > .inside::a= +fter, .page-basic > .header-menu > .inside::after, .page-basic > .top-menu = +> .inside::after, .page-basic > .main-menu > .inside::after, .page-basic > = +.site-title > .inside::after, .page-basic > .footer-first > .inside::after,= + .page-basic > .footer-seccond > .inside::after { content: ""; display: tab= +le; clear: both; } + +.page-basic > .header { background-color: rgb(135, 22, 40); } + +.page-basic > .header .header-wrapper { padding: 10px 0px 7px; } + +.page-basic > .content { margin: 15px 0px; } + +.page-basic > .content input[type=3D"text"], .page-basic > .content input[t= +ype=3D"password"], .page-basic > .content textarea, .page-basic > .content = +select, .page-basic > .content input[type=3D"file"], .page-basic > .content= + input[type=3D"email"], .page-basic > .content input[type=3D"number"] { wid= +th: 100%; } + +.page-basic > .content .general-left { margin-bottom: 35px; } + +.page-basic > .content .general-left::before, .page-basic > .content .gener= +al-left::after { content: ""; display: table; } + +.page-basic > .content .general-left::after { clear: both; } + +.page-basic > .content .reverse .general-left { margin-top: 25px; } + +.page-basic > .header-menu { display: none; } + +@media (min-width: 690px) { + .page-basic > .content .general-left { margin-bottom: 0px; } + .page-basic > .content .reverse .general-left { margin-top: 0px; } +} + +.general-left-title .social_media_exist .author-details { padding-right: 10= +0px; } + +.general-left-title .sub-header { margin-top: 5px; } + +.general-left-title .sub-header.social_media_exist { position: relative; } + +.general-left-title .author-details { font-style: italic; color: rgb(135, 1= +35, 135); font-size: 0.875rem; } + +.general-left-title .author-details > div { display: inline; } + +.general-left-title .author-details > div > p { display: inline; } + +.general-left-title.author_exist.social_media_exist .author-details { width= +: 53.9157%; float: left; margin-right: 7.53012%; display: inline; } + +.general-left-title.author_exist.social_media_exist .social-media-content {= + width: 38.5542%; float: right; margin-right: 0px; display: inline; } + +@media (min-width: 690px) { + .general-left-title.author_exist.social_media_exist .author-details { wid= +th: 65.247%; float: left; margin-right: 4.25894%; margin-top: 3px; } + .general-left-title.author_exist.social_media_exist .social-media-content= + { width: 30.494%; float: right; margin-right: 0px; } +} + +@media (min-width: 894px) { + .general-left-title.author_exist.social_media_exist .author-details { wid= +th: 73.9353%; float: left; margin-right: 4.25894%; } + .general-left-title.author_exist.social_media_exist .social-media-content= + { width: 21.8058%; float: right; margin-right: 0px; } +} + +.top_title_exist { margin-top: 25px; } + +.pane-pane-messages { margin-bottom: 10px; } + +ul.tabs, .action-links { font-size: 0.75em; margin-bottom: 10px; } + +ul.tabs li, .action-links li { float: left; margin-right: 10px; margin-bott= +om: 10px; } + +ul.tabs li a, .action-links li a { background-color: rgb(170, 170, 170); pa= +dding: 6px 12px; color: white; display: block; border-radius: 10px; } + +ul.tabs li a:hover, .action-links li a:hover { background-color: rgb(46, 46= +, 46); } + +.links { font-size: 0.75em; } + +.links li { display: inline-block; } + +.links a { background-color: rgb(170, 170, 170); padding: 3px 6px; border-r= +adius: 3px; display: inline-block; margin: 3px 3px 3px 0px; color: white !i= +mportant; } + +.links a:hover { background-color: rgb(46, 46, 46); text-decoration: none != +important; } + +.mobile-block { max-height: 100%; margin: 0px -13px -7px; background: rgb(2= +35, 235, 223); padding: 13px; } + +.mobile-block.active { display: block !important; } + +.mobile-block .header-menu { text-align: center; font-size: 0.8125em; paddi= +ng: 10px 0px 0px; } + +.mobile-block .header-menu ul li { display: inline-block; margin: 0px 6px; = +} + +.mobile-block .header-menu ul li a { color: rgb(102, 102, 102); display: bl= +ock; text-decoration: none; } + +.mobile-block .header-menu ul li a:hover { color: rgb(135, 22, 40); } + +.pane-system-user-menu { text-align: right; color: white; } + +.pane-system-user-menu ul { list-style-type: none; display: inline-block; } + +.pane-system-user-menu li { float: left; margin-left: 11px; } + +.pane-system-user-menu li a { font-size: 0.6875rem; color: inherit; text-de= +coration: none; display: block; font-family: inherit; } + +.pane-system-user-menu li a::before { display: none; } + +.pane-search-block, .pane-search-form { display: none; } + +.mobile-search-form .pane-search-block, .mobile-search-form .pane-search-fo= +rm { display: block; } + +.header.with_user_search form { padding-right: 38px; position: relative; } + +.header.with_user_search form input[type=3D"text"] { width: 100%; box-shado= +w: rgb(161, 161, 161) 0px 0px 0px 1px inset, black 0px 0px 0px 0px; border-= +radius: 0px; border: 0px none; padding: 5px 6px; appearance: none; letter-s= +pacing: 1px; } + +.header.with_user_search form input[type=3D"submit"] { position: absolute; = +top: 0px; right: 0px; margin: 0px; text-align: center; height: 26px; width:= + 32px; padding: 0px; line-height: 1; border-radius: 0px; background-color: = +white; appearance: none; border: 0px none; box-shadow: rgb(161, 161, 161) 0= +px 0px 0px 1px inset, black 0px 0px 0px 0px; } + +@media (min-width: 690px) { + .pane-search-block, .pane-search-form { display: none; } + .external-organisation .user-search .pane-search-block, .external-organis= +ation .user-search .pane-search-form { display: inline-block !important; wi= +dth: 45%; } +} + +.logo-container { width: 47.8705%; float: left; margin-right: 4.25894%; dis= +play: inline; } + +.user-search { width: 47.8705%; float: right; margin-right: 0px; display: i= +nline; text-align: right; } + +.pane-system-user-menu { display: none !important; } + +@media (min-width: 690px) { + .pane-system-user-menu { min-width: 130px; } +} + +.mob-icon, .user-icons a, .share-block-wrapper a { width: 22px; height: 20p= +x; position: relative; cursor: pointer; display: inline-block; color: white= +; font-size: 1.5625rem; margin-left: 15px; user-select: none; overflow: hid= +den; text-indent: 9000px; vertical-align: top; } + +.mob-icon:hover, .user-icons a:hover, .share-block-wrapper a:hover { color:= + rgb(243, 241, 235); } + +.mob-icon.active, .user-icons a.active, .share-block-wrapper a.active { col= +or: rgb(184, 115, 125); } + +.mob-icon::before, .user-icons a::before, .share-block-wrapper a::before { = +position: absolute; top: 0px; left: 0px; line-height: 1; } + +.mob-icon.menu-icon, .user-icons a.menu-icon, .share-block-wrapper a.menu-i= +con { margin-left: 0px; display: none; } + +.mob-icon.menu-icon::before, .user-icons a.menu-icon::before, .share-block-= +wrapper a.menu-icon::before { content: "=EE=98=91"; font-size: 1.5625rem; t= +op: -2px; } + +.mob-icon.icon-account::before, .user-icons a.icon-account::before, .share-= +block-wrapper a.icon-account::before { content: "=EE=98=92"; font-size: 1.2= +5rem; } + +.mob-icon.icon-logout::before, .user-icons a.icon-logout::before, .share-bl= +ock-wrapper a.icon-logout::before { content: "=EE=98=94"; font-size: 1.25re= +m; } + +.mob-icon.icon-login::before, .user-icons a.icon-login::before, .share-bloc= +k-wrapper a.icon-login::before { content: "=EE=98=9B"; font-size: 1.3125rem= +; } + +.mob-icon.search-icon::before, .user-icons a.search-icon::before, .share-bl= +ock-wrapper a.search-icon::before { content: "=EE=98=A2"; font-size: 1.25re= +m; } + +.mob-icon.share-facebook::before, .user-icons a.share-facebook::before, .sh= +are-block-wrapper a.share-facebook::before { content: "=EE=98=84"; } + +.mob-icon.share-twitter::before, .user-icons a.share-twitter::before, .shar= +e-block-wrapper a.share-twitter::before { content: "=EE=98=90"; } + +.mob-icon.share-googleplus::before, .user-icons a.share-googleplus::before,= + .share-block-wrapper a.share-googleplus::before { content: "=EE=98=83"; } + +.mob-icon.share-reddit::before, .user-icons a.share-reddit::before, .share-= +block-wrapper a.share-reddit::before { content: "=EE=98=AE"; } + +.mob-icon.share-delicious::before, .user-icons a.share-delicious::before, .= +share-block-wrapper a.share-delicious::before { content: "=EE=98=AF"; } + +.social-media-content { text-align: right; } + +.social-media-content > div { display: inline-block; } + +.left-title-wrapper .social-media-content { position: absolute; bottom: 8px= +; right: 0px; } + +.social_media_exist .social-media-content { position: absolute; top: 1px; r= +ight: 0px; } + +.share-block-wrapper > div { float: left; margin-left: 10px; } + +.share-block-wrapper > div:first-child { margin-left: 0px; } + +.share-block-wrapper a { font-size: 1.3125rem; margin: 0px; width: 21px; he= +ight: 22px; color: rgb(153, 153, 153) !important; } + +.share-block-wrapper a:hover { color: rgb(135, 22, 40) !important; } + +.pane-page-site-name { font-size: 1.625rem; } + +.external-organisation .pane-page-site-name { font-size: 2.1875rem; font-we= +ight: normal; } + +.footer-first .pane-page-site-name { font-size: 0.875rem; line-height: 1; f= +ont-weight: bold; } + +@media (min-width: 480px) { + .general-two-col { } + .general-two-col::before, .general-two-col::after { content: ""; display:= + table; } + .general-two-col::after { clear: both; } + .footer-first .pane-page-site-name { border-right: 1px solid; padding-rig= +ht: 17px; margin-right: 15px; } +} + +.pane-site-name-abbreviation { font-size: 3rem; font-weight: bold; margin-r= +ight: 14px; } + +.pane-page-logo { float: left; } + +.pane-page-logo img { width: 115px; } + +.no-svg .pane-page-logo img.svg { display: none; } + +.svg .pane-page-logo img.svg { display: inline; } + +.header-date { font-size: 0.75em; letter-spacing: 1px; font-weight: bold; c= +olor: white; float: left; margin: 15px 0px 0px 10px; } + +.site-title { margin: 10px 0px; } + +.external-organisation .site-title .inside > div { display: inline-block; v= +ertical-align: baseline; word-break: break-all; } + +.footer-first { background-color: rgb(235, 235, 223); color: rgb(136, 101, = +90); padding: 16px 0px 0px; } + +.footer-first .general-left .inside > div { margin-bottom: 10px; } + +.footer-first .general-right .inside > div { display: block; text-align: ce= +nter; } + +.footer-first ul.menu { display: block; font-size: 0.75rem; } + +.footer-first ul.menu li { float: left; margin-right: 20px; } + +.footer-first ul.menu li::after { content: ":"; margin-left: 20px; } + +.footer-first ul.menu li:last-child { margin-right: 0px; } + +.footer-first ul.menu li:last-child::after { display: none; } + +.footer-first ul.menu a { color: inherit; } + +.footer-first .pane-site-address { display: none; } + +@media (min-width: 480px) { + .footer-first .general-left { margin-bottom: 20px; } + .footer-first .general-left .inside > div { display: inline-block; vertic= +al-align: text-top; margin-bottom: 0px; } + .footer-first .pane-site-address { font-size: 0.6875rem; margin-top: 0px;= + display: block !important; } +} + +.social-icon-block { display: inline-block; vertical-align: bottom; margin:= + 5px auto 0px; } + +.social-icon-block > div { float: left; margin-right: 15px; margin-bottom: = +15px; } + +.social-icon-block > div:last-child { margin-right: 0px; } + +.social-icon-block > div a { text-indent: -9000px; display: block; color: r= +gb(187, 184, 146); position: relative; width: 36px; height: 36px; overflow:= + hidden; text-align: left; } + +.social-icon-block > div a::after { position: absolute; top: 50%; left: 50%= +; margin-top: -22px; margin-left: -18px; font-size: 2.1875rem; } + +.social-icon-block > div a:hover { color: rgb(135, 22, 40); } + +.social-icon-block > div.social-icon-twitter a::after { content: "=EE=98=90= +"; } + +.social-icon-block > div.social-icon-facebook a::after { content: "=EE=98= +=84"; } + +.social-icon-block > div.social-icon-googleplus a::after { content: "=EE=98= +=83"; } + +.social-icon-block > div.social-icon-youtube a::after { content: "=EE=98=80= +"; } + +.social-icon-block > div.social-icon-flickr a::after { content: "=EE=98=81"= +; } + +@media (min-width: 480px) { + .social-icon-block { margin: 0px auto; } +} + +.general-left .pane-bundle-share-block { margin-bottom: 25px; } + +.footer-seccond { background-color: rgb(135, 135, 135); color: white; font-= +size: 0.625rem; padding: 16px 0px 7px; } + +.footer-seccond ul { text-transform: uppercase; margin-top: 9px; margin-bot= +tom: 9px; } + +.footer-seccond ul li { margin-bottom: 5px; } + +.footer-seccond ul li a { display: block; position: relative; padding: 6px = +20px 6px 6px; background-color: rgb(170, 170, 170); } + +.footer-seccond ul li a::after { content: "=3D"; position: absolute; font-s= +ize: 1.5625rem; right: 5px; top: 50%; margin-top: -13px; line-height: 1; } + +.footer-seccond a { color: inherit; } + +.footer-seccond .pane-1, .footer-seccond .pane-2, .footer-seccond .pane-sit= +e-address { display: inline; } + +.footer-seccond .pane-1 p, .footer-seccond .pane-2 p, .footer-seccond .pane= +-site-address p { display: block; } + +.footer-seccond .pane-1 a, .footer-seccond .pane-2 a, .footer-seccond .pane= +-site-address a { text-transform: uppercase; font-weight: bold; } + +.footer-seccond .pane-1 p { margin-bottom: 5px; } + +.footer-seccond .pane-2 { display: block; clear: both; } + +@media (min-width: 800px) { + .footer-seccond .pane-1 p, .footer-seccond .pane-2 p { display: inline; } +} + +.pager { margin-top: 30px; } + +.pager li { float: left; font-size: 1.0625em; line-height: 14px; display: i= +nline-block; vertical-align: middle; background-color: rgb(241, 241, 241); = +margin-right: 13px; } + +.pager li.pager-last, .pager li.pager-first { display: none; } + +.pager li.pager-current { background-color: rgb(235, 235, 223); padding: 14= +px 11px 10px !important; } + +.pager li a { display: block; color: rgb(135, 22, 40); padding: 14px 11px 1= +0px; } + +.pager li a:hover { background-color: rgb(135, 22, 40); color: white; text-= +decoration: none !important; } + +.event-calendar-page .pager { margin-top: 0px; } + +.event-calendar-page .pager li { height: 50px; width: 27px; top: 0px; posit= +ion: absolute; margin: 0px; display: block; background-color: transparent; = +line-height: inherit; } + +.event-calendar-page .pager li a { display: block; overflow: hidden; width:= + 100%; height: 100%; text-indent: -999px; color: rgb(46, 46, 46); position:= + absolute; top: 0px; left: 0px; line-height: 1; } + +.event-calendar-page .pager li a:hover { color: rgb(135, 22, 40); backgroun= +d: none; } + +.event-calendar-page .pager li a::before { position: absolute; font-size: 3= +.125rem; top: 45%; left: 0px; margin-top: -25px; } + +.event-calendar-page .pager li.date-prev { left: 0px; } + +.event-calendar-page .pager li.date-prev a::before { content: "=EE=98=89"; = +} + +.event-calendar-page .pager li.date-next { right: 0px; } + +.event-calendar-page .pager li.date-next a::before { content: "=3D"; } + +.event-calendar-page .date-nav-wrapper { text-align: center; } + +.event-calendar-page .date-nav { position: relative; display: inline-block;= + padding: 0px 60px; } + +.event-calendar-page .date-nav .date-heading { text-align: center; } + +.event-calendar-page .date-nav h3 { font-size: 1.5rem; line-height: 50px; } + +.event-calendar-page .date-nav .pager { width: 100%; } + +.event-calendar-page .legend-item-wrapper > div { display: inline-block; ve= +rtical-align: middle; } + +.calendar-page { color: rgb(71, 71, 71); } + +.calendar-page a { color: rgb(71, 71, 71); } + +.calendar-page .view-header { padding: 15px 15px 0px; } + +.calendar-page .view-content { padding: 5px; } + +.calendar-page .view-footer li { padding: 15px; } + +.calendar-page .view-header, .calendar-page .view-content, .calendar-page .= +view-footer li { background-color: rgb(243, 241, 235); } + +.calendar-page .view-content, .calendar-page .view-footer li.first { margin= +-bottom: 5px; } + +.calendar-page .calendar-empty { display: none; } + +.calendar-page .view-header ul { position: relative; text-transform: upperc= +ase; } + +.calendar-page .view-header ul li { position: static; display: block; text-= +align: center; font-size: 1rem; font-weight: bold; } + +.calendar-page .view-header ul li a:hover { text-decoration: none; } + +.calendar-page .view-header ul li.first, .calendar-page .view-header ul li.= +last { position: absolute; font-size: 0.875rem; top: 2px; font-weight: norm= +al; } + +.calendar-page .view-header ul li.first a, .calendar-page .view-header ul l= +i.last a { user-select: none; line-height: 1; } + +.calendar-page .view-header ul li.first a::after, .calendar-page .view-head= +er ul li.first a::before, .calendar-page .view-header ul li.last a::after, = +.calendar-page .view-header ul li.last a::before { position: relative; top:= + 2px; } + +.calendar-page .view-header ul li.first { left: 0px; } + +.calendar-page .view-header ul li.first a::before { content: "=EE=98=A8"; } + +.calendar-page .view-header ul li.last { right: 0px; } + +.calendar-page .view-header ul li.last a::after { content: "=EE=98=AB"; } + +.calendar-page table { margin-bottom: 0px !important; width: 100%; } + +.calendar-page table td, .calendar-page table th { text-align: center; font= +-size: 0.75rem; color: inherit; background-color: transparent !important; } + +.calendar-page table thead th { border-color: white !important; border-styl= +e: solid !important; border-width: 3px 0px !important; padding: 6px 0px !im= +portant; } + +.calendar-page table tbody tr:first-child td { padding-top: 15px !important= +; } + +.calendar-page table tbody td { border: 0px none !important; padding: 0px != +important; width: auto !important; } + +.calendar-page table tbody td .month { display: inline-block; } + +.calendar-page table tbody td a { padding: 6px; display: inline-block; } + +.calendar-page table tbody td a.active { background-color: rgb(135, 22, 40)= +; color: white; font-weight: bold; } + +.content .general-two-col .general-left .faq-list ul { padding-left: 0px; } + +.content .general-two-col .general-left .faq-list ul li { border-bottom: 1p= +x solid rgb(243, 241, 235); margin-bottom: 10px; padding-bottom: 10px; list= +-style-type: none; } + +.faq-list li.inactive .views-field-title::before { content: "+"; margin-top= +: -9px; } + +.general-left .faq-list li { border-bottom: 1px solid rgb(243, 241, 235); m= +argin-bottom: 10px; padding-bottom: 10px; } + +.general-left .faq-list li[class^=3D"pager-"] { padding: 0px; } + +.faq-list li:last-child { border-bottom: 0px; margin-bottom: 0px; padding-b= +ottom: 0px; } + +.faq-list .views-field-title { cursor: pointer; color: rgb(135, 22, 40); fo= +nt-weight: bold; user-select: none; position: relative; padding-left: 15px;= + } + +.faq-list .views-field-title:hover { text-decoration: none; } + +.faq-list .views-field-title::before { content: "-"; width: 10px; height: 1= +0px; left: 0px; top: 50%; line-height: 1; position: absolute; margin-top: -= +11px; } + +.faq-list .views-field-field-slac-faq-answer { font-size: 0.875rem; line-he= +ight: 20px; padding-left: 20px; margin-top: 10px; margin-bottom: 5px; } + +.faq-comments h2, .pane-node-comments h2 { font-size: 1.125rem; margin-top:= + 20px; margin-bottom: 10px; } + +.faq-comments header, .pane-node-comments header { border-bottom: 1px solid= + rgb(243, 241, 235); padding-bottom: 2px; } + +.faq-comments .field-name-comment-body, .pane-node-comments .field-name-com= +ment-body { margin-bottom: 15px; } + +.faq-comments article, .pane-node-comments article { margin-bottom: 15px; } + +.faq-comments .links li, .pane-node-comments .links li { border-bottom: 0px= +; margin-bottom: 0px; } + +.feed-icon { display: none; } + +.front .pane-page-content .general-left .panel-pane { margin-bottom: 36px; = +} + +.front .pane-page-content .general-left .panels-ipe-portlet-wrapper .panel-= +pane { margin-bottom: 0px; } + +.front .pane-page-content .general-left .panels-ipe-portlet-wrapper { margi= +n-bottom: 36px; } + +.front .page-basic-io .general-left .panels-ipe-portlet-wrapper, .front .pa= +ge-basic-io .general-left .panel-pane { margin-bottom: 23px; } + +.front .page-basic-io .inner_right .panels-ipe-portlet-wrapper, .front .pag= +e-basic-io .inner_right .pane-fieldable-panels-pane { margin-bottom: 8px; } + +.pane-page-content .general-left .pane-menu-tree h2.pane-title, .pane-page-= +content .general-left-title .pane-menu-tree h2.pane-title, .pane-page-conte= +nt .general-title .pane-menu-tree h2.pane-title, .pane-page-content .genera= +l-left .pane-menu-tree h2.panel-title, .pane-page-content .general-left-tit= +le .pane-menu-tree h2.panel-title, .pane-page-content .general-title .pane-= +menu-tree h2.panel-title { border-bottom: 0px none; color: rgb(135, 22, 40)= +; font-size: 1.375rem; font-weight: normal; padding-bottom: 5px; } + +h2.pane-title i, h2.panel-title i { text-transform: capitalize; } + +.pane-page-content .general-left h2.pane-title, .pane-page-content .general= +-left-title h2.pane-title, .pane-page-content .general-title h2.pane-title,= + .pane-page-content .general-left h2.panel-title, .pane-page-content .gener= +al-left-title h2.panel-title, .pane-page-content .general-title h2.panel-ti= +tle { border-bottom: 3px solid rgb(243, 241, 235); padding-bottom: 5px; } + +.front .pane-page-content .general-left h2.pane-title, .front .pane-page-co= +ntent .general-left-title h2.pane-title, .front .pane-page-content .general= +-title h2.pane-title, .front .pane-page-content .general-left h2.panel-titl= +e, .front .pane-page-content .general-left-title h2.panel-title, .front .pa= +ne-page-content .general-title h2.panel-title { color: rgb(98, 98, 98); bor= +der-bottom: 6px solid rgb(231, 235, 230); font-size: inherit; margin-bottom= +: 23px; padding-bottom: 3px; } + +.pane-page-content .general-right h2.pane-title, .pane-page-content .right-= +sidebar h2.pane-title, .pane-page-content .general-right h2.panel-title, .p= +ane-page-content .right-sidebar h2.panel-title { color: rgb(68, 68, 68); fo= +nt-size: 0.875rem; margin-bottom: 13px; padding-bottom: 7px; border-bottom:= + 3px solid rgb(243, 241, 235); text-transform: uppercase; letter-spacing: 0= +.05em; } + +.pane-page-content .general-right .region-grey-background-style h2.pane-tit= +le, .pane-page-content .general-right .region-grey-background-style h2.pane= +l-title { border-bottom-color: white; } + +.pane-page-content .right-sidebar h2.pane-title, .pane-page-content .right-= +sidebar h2.panel-title { border-bottom-color: white; } + +.page-search .pane-page-content h2 { border-bottom: 3px solid rgb(243, 241,= + 235); padding-bottom: 5px; margin-bottom: 20px; } + +.pane-page-content .general-left-title .left-title-wrapper h2 { border-bott= +om: 0px none; padding-right: 100px; padding-bottom: 0px; } + +.pane-page-content .general-right .event-wrapper-inner h2 { border-bottom: = +0px none; margin-bottom: 1px; padding-bottom: 0px; font-size: 0.75rem; } + +.left-title-wrapper { position: relative; border-bottom: 3px solid rgb(243,= + 241, 235); padding-bottom: 5px; } + +.general-right { font-size: 0.8125rem; color: rgb(112, 97, 97); } + +.content .general-right .panels-ipe-portlet-wrapper { margin-bottom: 18px; = +} + +.general-right .panel-pane { margin-bottom: 18px; } + +.general-right .panel-pane:last-child { margin-bottom: 0px; } + +.inner_right .panel-pane { margin-bottom: 20px; } + +.right-sidebar .panel-pane { background-color: rgb(243, 241, 235); padding:= + 13px 22px; } + +.region-grey-background-style { background-color: rgb(243, 241, 235); paddi= +ng: 13px 22px; } + +@media (min-width: 690px) { + .region-grey-background-style { padding: 0px; } +} + +.view-blog-page .views-row { margin-bottom: 25px; } + +.view-blog-page .views-row:last-child { margin-bottom: 0px; } + +@media (min-width: 690px) { + .view-blog-page .views-row { margin-bottom: 49px; } +} + +.page-basic > .content table { margin-bottom: 25px; } + +.page-basic > .content table thead { text-transform: uppercase; } + +.page-basic > .content table thead th { background-color: rgb(235, 235, 223= +); font-weight: bold; } + +.page-basic > .content table td, .page-basic > .content table th { border: = +1px solid rgb(235, 235, 223); padding: 6px; } + +article.node-blog .submitted { font-style: italic; color: rgb(135, 135, 135= +); } + +article.node-blog .submitted a { color: rgb(184, 115, 125); position: relat= +ive; } + +article.node-blog .submitted a:hover { text-decoration: none; } + +article.node-blog .submitted a::after { position: absolute; width: 100%; bo= +rder-bottom: 1px dashed; bottom: 0px; left: 0px; display: none; content: ""= +; } + +article.node-blog .submitted a:hover::after { display: inline-block; } + +article.node-blog.view-mode-full header { display: none; } + +article.node-blog.view-mode-full .submitted { margin-top: 5px; font-size: 0= +.875rem; } + +article.node-blog.view-mode-full .field-name-field-slac-blog-image { margin= +-bottom: 25px; } + +article.node-blog.node-teaser { background-color: rgb(241, 241, 241); font-= +size: 0.875rem; } + +article.node-blog.node-teaser .separator { border-bottom: 3px solid white; = +} + +article.node-blog.node-teaser header h2 { font-size: 1.25em; padding: 0px; = +} + +article.node-blog.node-teaser header h2 a { color: rgb(135, 22, 40); displa= +y: block; padding: 13px 35px 9px 13px; position: relative; } + +article.node-blog.node-teaser header h2 a::after { content: "=EE=98=88"; po= +sition: absolute; right: 3px; top: 50%; margin-top: -17px; font-size: 1.875= +rem; color: rgb(200, 211, 199); } + +article.node-blog.node-teaser header h2 a:hover { background-color: rgb(235= +, 235, 223); } + +article.node-blog.node-teaser header h2 a:hover::after { color: rgb(135, 22= +, 40); } + +article.node-blog.node-teaser header .submitted { padding: 9px 13px 7px; fo= +nt-size: 0.75rem; } + +article.node-blog.node-teaser .blog-wrapper { padding: 13px; font-size: 0.8= +75rem; } + +article.node-blog.node-teaser .blog-wrapper h2 { font-size: inherit; } + +article.node-blog.node-teaser .field-name-field-slac-blog-image { margin-bo= +ttom: 10px; } + +article.node-blog.node-teaser .field-name-field-slac-blog-image a { border:= + 3px solid white; display: block; box-shadow: rgb(243, 242, 235) 0px 0px 0p= +x 3px; } + +article.node-blog.node-teaser .field-name-field-slac-blog-image img { displ= +ay: block; } + +article.node-blog.node-teaser .field-type-text-with-summary { color: rgb(71= +, 71, 71); } + +article.node-blog.node-teaser .field-type-text-with-summary p { line-height= +: 21px; margin-bottom: 5px; } + +article.node-blog.node-teaser .links { padding: 10px 42px; } + +article.node-blog.node-teaser .links li { float: left; margin-right: 10px; = +} + +article.node-blog.node-teaser .links li a { display: block; } + +.pane-node-field-slac-blog-image { margin-bottom: 25px; } + +@media (min-width: 480px) { + article.node-blog.node-teaser .field-name-field-slac-blog-image { float: = +left; margin-right: 25px; width: 215px; } + article.node-blog.node-teaser .blog-wrapper { } + article.node-blog.node-teaser .blog-wrapper::before, article.node-blog.no= +de-teaser .blog-wrapper::after { content: ""; display: table; } + article.node-blog.node-teaser .blog-wrapper::after { clear: both; } +} + +@media (min-width: 691px) and (max-width: 893px) { + .front .pane-page-content .general-left > .inside .frontpage-wrapper { wi= +dth: 65.247%; float: left; margin-right: 4.25894%; } +} + +.frontpage-wrapper .panel-pane { } + +.frontpage-wrapper .panel-pane::before, .frontpage-wrapper .panel-pane::aft= +er { content: ""; display: table; } + +.frontpage-wrapper .panel-pane::after { clear: both; } + +@media (min-width: 1200px) { + article.node-blog.node-teaser header h2 a { padding: 13px 40px 9px; } + article.node-blog.node-teaser header .submitted { padding: 9px 42px 7px; = +} + article.node-blog.node-teaser .blog-wrapper { display: table; padding: 14= +px 42px; font-size: 0.875rem; } + article.node-blog.node-teaser .blog-wrapper > .field { vertical-align: to= +p; } + article.node-blog.node-teaser .field-name-field-slac-blog-image { width: = +215px; display: table-cell; padding-right: 35px; box-sizing: content-box; v= +ertical-align: top; top: 5px; position: relative; float: none; } +} + +.profile-name { font-size: 1.25rem; font-weight: bold; } + +.profile-affiliation { font-size: 1rem; } + +.profile-name-block { margin-bottom: 1em; } + +.profile-info-brief .profile-name-block { margin-top: 100px; } + +.profile-info-brief .views-field-field-prf-bio-bio { margin-bottom: 0px; } + +.profile-info-brief .views-row-1 { margin-bottom: 18px; } + +.profile-info-brief .views-field-field-prf-bio-bio { font-size: 0.875rem; m= +argin-bottom: 10px; line-height: 20px; } + +.profile-info-brief .views-more-link { margin-top: 15px; display: inline-bl= +ock; vertical-align: middle; text-transform: uppercase; color: rgb(135, 22,= + 40); font-size: 0.6875rem; letter-spacing: 0.05em; width: 100%; line-heigh= +t: 1; } + +.profile-info-brief .views-more-link::after { content: "=EE=98=9E"; font-si= +ze: 0.875rem; vertical-align: middle; line-height: 1; } + +@media (min-width: 480px) { + .profile-info-brief { } + .profile-info-brief::before, .profile-info-brief::after { content: ""; di= +splay: table; } + .profile-info-brief::after { clear: both; } + .profile-info-brief .views-field-field-prf-contact-photo { margin-right: = +0px; position: relative; z-index: 1; } + .profile-info-brief .views-row-1 { margin-bottom: 34px; } + .profile-info-brief .views-row-2, .profile-info-brief .profile-name, .pro= +file-info-brief .views-field-field-prf-contact-affiliation { padding-left: = +235px; } + .profile-info-brief .profile-name, .profile-info-brief .views-field-field= +-prf-contact-affiliation { background-color: rgb(243, 241, 235); width: 100= +%; display: block; position: relative; top: 20px; } + .profile-info-brief .profile-name { padding-top: 6px; } + .profile-info-brief .views-field-field-prf-contact-affiliation { padding-= +bottom: 6px; } +} + +@media (min-width: 690px) { + .profile-info-brief .views-row-2, .profile-info-brief .profile-name, .pro= +file-info-brief .views-field-field-prf-contact-affiliation { padding-left: = +204px; } + .profile-info-brief .views-field-field-prf-contact-photo { width: 188px; = +} +} + +.profile-info-full { font-size: 0.875rem; } + +.profile-info-full .views-label { font-size: 1rem; font-weight: bold; color= +: rgb(102, 102, 102); margin-bottom: 5px; padding-left: 3px; display: inlin= +e-block; letter-spacing: 0.05em; } + +.profile-info-full p { line-height: 20px; } + +.profile-info-full .views-row-1 { margin-bottom: 15px; } + +.profile-info-full .views-row-2 { margin-bottom: 23px; } + +.profile-info-full .profile-textformatter-list { list-style-type: disc; pad= +ding-left: 17px; } + +.profile-info-full .profile-textformatter-list li { margin-bottom: 17px; ov= +erflow: visible !important; } + +.profile-info-full .profile-textformatter-list li a { display: inline; vert= +ical-align: middle; } + +.profile-info-full .profile-maxlist-more { line-height: inherit; text-align= +: right; position: absolute; right: 0px; bottom: -26px; } + +.profile-info-full .profile-maxlist-more a { background: -webkit-linear-gra= +dient(top, rgb(252, 252, 252) 0%, rgb(237, 237, 237) 100%); color: rgb(71, = +71, 71); padding: 8px 15px 5px 47px; display: inline-block; font-size: 0.68= +75rem; position: relative; } + +.profile-info-full .profile-maxlist-more a::before { position: absolute; le= +ft: 14px; top: 50%; margin-top: -10px; font-size: 1.25rem; content: "=EE=98= +=96"; } + +.profile-info-full .profile-maxlist-more a.collapsed-list::before { content= +: "=EE=98=95"; } + +span[class*=3D"document-type-"] { display: inline-block; vertical-align: mi= +ddle; } + +@media (min-width: 480px) { + .profile-info-full .views-row-1 { } + .profile-info-full .views-row-1::before, .profile-info-full .views-row-1:= +:after { content: ""; display: table; } + .profile-info-full .views-row-1::after { clear: both; } +} + +.views-field-field-prf-bio-education, .views-field-field-prf-bio-pexp, .vie= +ws-field-field-prf-bio-ar, .views-field-field-prf-bio-ha, .views-field-fiel= +d-prf-bio-pa, .views-field-field-prf-bio-pub, .views-field-field-prf-bio-tp= + { margin-bottom: 50px; display: block; position: relative; } + +.views-field-field-prf-bio-education .field-content, .views-field-field-prf= +-bio-pexp .field-content, .views-field-field-prf-bio-ar .field-content, .vi= +ews-field-field-prf-bio-ha .field-content, .views-field-field-prf-bio-pa .f= +ield-content, .views-field-field-prf-bio-pub .field-content, .views-field-f= +ield-prf-bio-tp .field-content { padding: 25px 25px 8px; border-width: 6px = +2px 2px; border-style: solid; border-color: rgb(231, 235, 230); border-imag= +e: initial; font-size: 0.8125rem; } + +.views-field-field-prf-bio-bio { margin-bottom: 3em; } + +.views-field-field-prf-contact-photo { border-width: 8px 10px; border-style= +: solid; border-color: rgb(243, 241, 235); border-image: initial; display: = +inline-block; margin-right: 15px; margin-bottom: 25px; } + +.views-field-field-prf-contact-photo img { display: block; } + +table.sticky-table { margin-bottom: 25px; } + +table.sticky-table th { font-weight: bold; background-color: rgb(241, 241, = +241); } + +table.sticky-table td, table.sticky-table th { border: 1px solid rgb(243, 2= +41, 235); padding: 6px; } + +.site-title { position: relative; color: rgb(53, 53, 53); } + +.node-type-list dt, .node-type-list dd { margin-bottom: 10px; } + +.node-type-list dt a { background-color: rgb(39, 108, 189); padding: 6px; b= +order-radius: 3px; color: white; font-size: 0.875rem; } + +.node-type-list dt a:hover { background-color: rgba(39, 108, 189, 0.8); } + +.news-landing h2, .kb-articles h2 { font-size: 1.125rem; margin-bottom: 4px= +; } + +.news-landing h2 a, .kb-articles h2 a { color: rgb(135, 22, 40); } + +.news-landing .field-name-body, .kb-articles .field-name-body { margin-bott= +om: 5px; } + +.news-landing .field-name-body p, .kb-articles .field-name-body p { color: = +rgb(85, 85, 85); font-size: 0.75rem; line-height: 20px; } + +.news-landing .news-image, .news-landing .article-image, .kb-articles .news= +-image, .kb-articles .article-image { margin-bottom: 10px; } + +.news-landing .field-name-field-slac-news-source a, .kb-articles .field-nam= +e-field-slac-news-source a { color: rgb(119, 119, 119); font-size: 0.75rem;= + font-style: italic; } + +.news-landing .views-row, .kb-articles .views-row { border-bottom: 2px soli= +d rgb(224, 222, 205); padding-bottom: 20px; margin-bottom: 20px; min-height= +: 155px; } + +.news-landing .views-row.views-row-last, .kb-articles .views-row.views-row-= +last { border-bottom: 0px; padding-bottom: 0px; margin-bottom: 0px; } + +@media (min-width: 480px) { + .news-landing article, .kb-articles article { position: relative; padding= +: 0px 15px 0px 0px; } + .news-landing article.image_exist .news-content, .news-landing article.im= +age_exist .article-content, .kb-articles article.image_exist .news-content,= + .kb-articles article.image_exist .article-content { padding-left: 170px; } + .news-landing article:hover .news-image img, .kb-articles article:hover .= +news-image img { opacity: 0.9; } + .news-landing .news-image, .news-landing .article-image, .kb-articles .ne= +ws-image, .kb-articles .article-image { max-width: 150px; position: absolut= +e; top: 2px; margin-bottom: 0px; } +} + +.projects-blocks-wrapper .project-title a { position: absolute; top: 0px; l= +eft: 0px; width: 100%; background-color: rgba(0, 0, 0, 0.45); color: white;= + text-transform: uppercase; font-weight: bold; padding: 9px; } + +.projects-blocks-wrapper .project-description { position: absolute; display= +: none; font-size: 0.875rem; color: white; top: 35px; left: 0px; padding: 9= +px; } + +.projects-blocks-wrapper .project-description a { color: inherit; } + +.projects-blocks-wrapper .project-image { position: static; } + +.projects-blocks-wrapper .project-image img, .projects-blocks-wrapper .proj= +ect-image a { display: block; } + +.projects-blocks-wrapper .project-block-wrapper { position: relative; margi= +n-bottom: 2em; overflow: hidden; } + +.projects-blocks-wrapper .project-block-wrapper::after { background-color: = +rgba(0, 0, 0, 0.6); width: 100%; height: 100%; position: absolute; content:= + ""; top: 0px; left: 0px; display: none; } + +.projects-blocks-wrapper .project-block-wrapper:hover::after { display: blo= +ck; } + +.projects-blocks-wrapper .project-block-wrapper:hover .project-description = +{ display: inline; } + +.projects-blocks-wrapper .project-block-wrapper:hover .project-title a, .pr= +ojects-blocks-wrapper .project-block-wrapper:hover .project-description { z= +-index: 10; } + +.projects-blocks-wrapper .project-block-wrapper:hover .project-title a { ba= +ckground-color: transparent; } + +@media (min-width: 321px) { + .projects-blocks-wrapper { } + .projects-blocks-wrapper::before, .projects-blocks-wrapper::after { conte= +nt: ""; display: table; } + .projects-blocks-wrapper::after { clear: both; } + .project-block-wrapper { float: left; width: 45%; margin-right: 10%; marg= +in-bottom: 10%; } + .project-block-wrapper:nth-child(2n+2) { margin-right: 0px; } +} + +@media (min-width: 480px) { + .project-block-wrapper { width: 30%; margin-right: 5%; margin-bottom: 5%;= + } + .project-block-wrapper.nth-2 { margin-right: 5%; } + .project-block-wrapper.nth-3 { margin-right: 0px; } +} + +@media (min-width: 690px) { + .project-block-wrapper { width: 45%; margin-bottom: 10%; margin-right: 10= +% !important; } + .project-block-wrapper.nth-2 { margin-right: 0px !important; } +} + +@media (min-width: 894px) { + .project-block-wrapper { width: 30%; margin-bottom: 5%; margin-right: 5% = +!important; } + .project-block-wrapper.nth-2 { margin-right: 5% !important; } + .project-block-wrapper.nth-3 { margin-right: 0px !important; } +} + +.more-link-template, .last-three-news .more-link, .event-calendar-block .mo= +re-link { border-top: 1px solid rgb(243, 241, 235); border-bottom: 1px soli= +d rgb(243, 241, 235); display: block; padding: 2px 0px 4px; text-align: rig= +ht; } + +.more-link-template a, .last-three-news .more-link a, .event-calendar-block= + .more-link a { color: rgb(135, 22, 40); font-size: 0.6875rem; display: inl= +ine-block; vertical-align: middle; } + +.more-link-template a::after, .last-three-news .more-link a::after, .event-= +calendar-block .more-link a::after { content: "=EE=98=85"; line-height: 1; = +vertical-align: middle; display: inline-block; font-size: 0.5625rem; } + +.last-three-news .views-row { margin-bottom: 8px; } + +.last-three-news .views-row:last-child { margin-bottom: 17px; } + +.last-three-news .views-field-title { margin-bottom: 2px; } + +.last-three-news .views-field-title a { color: rgb(135, 22, 40); font-size:= + 0.75rem; } + +.last-three-news .views-field-created { font-size: 0.6875rem; color: rgb(11= +9, 119, 119); } + +ul.news-archive-class li .item-list { margin-left: 10px; } + +.pane-node-field-slac-news-media, .pane-node-field-kb-article-media { margi= +n-bottom: 25px; } + +.article_panel_layout .author { border-right: 1px solid rgb(135, 135, 135);= + padding-right: 5px; margin-right: 7px; } + +.article_panel_layout .pane-node-created, .article_panel_layout .author, .a= +rticle_panel_layout .field-name-field-slac-news-date { display: inline-bloc= +k; font-size: 0.875rem; font-style: italic; color: rgb(135, 135, 135); line= +-height: 1; } + +.event-calendar-block .view-content { margin-bottom: 17px; } + +.event-block { position: relative; padding-left: 85px; border-bottom: 1px s= +olid rgb(243, 241, 235); margin-bottom: 10px; min-height: 40px; } + +.event-block:last-child { border-bottom: 0px none; } + +.event-block .date { position: absolute; top: 0px; left: 0px; font-family: = +Georgia, "Times New Roman", Times, serif; color: rgb(98, 98, 98); line-heig= +ht: 1; } + +.touch .event-block .date { font-family: Arial, Helvetica, sans-serif; } + +.event-block .date i { font-size: 1.5rem; position: absolute; top: 5px; lef= +t: 0px; } + +.touch .event-block .date i { top: 10px; } + +.event-block .date ins { font-size: 0.75rem; text-decoration: none; text-tr= +ansform: uppercase; display: block; } + +.event-block .events { font-size: 0.6875rem; } + +.event-block .event { color: rgb(98, 98, 98); padding-right: 25px; margin-b= +ottom: 10px; line-height: 16px; position: relative; } + +.event-block a { color: rgb(135, 22, 40); } + +.event-block a.icon { position: absolute; line-height: 1; right: 3px; top: = +0px; font-size: 0.9375rem; color: rgb(98, 98, 98); } + +.event-block a.icon::after { content: "=EE=98=9F"; } + +@media (min-width: 691px) and (max-width: 1138px) { + .event-block { padding-left: 0px; } + .event-block .date { position: static; display: block; min-height: 35px; = +} +} + +.pane-bundle-slac-sidebar-block p { font-size: 0.8125rem; line-height: 18px= +; } + +.pane-bundle-slac-sidebar-block .field-name-field-sb-image { float: right; = +max-height: 125px; margin: 0px 0px 10px 10px; } + +.pane-bundle-slac-sidebar-block .field-name-field-subtitle { position: rela= +tive; top: -3px; font-size: 0.875rem; font-weight: bold; margin-bottom: 5px= +; } + +.general-right .pane-bundle-slac-sidebar-block .field-item { padding-left: = +10px; } + +.general-right .pane-bundle-slac-sidebar-block p { color: rgb(98, 98, 98); = +font-size: 0.75rem; margin-bottom: 2px; font-weight: bold; } + +.general-right .pane-bundle-slac-sidebar-block ul { list-style-type: disc; = +padding-left: 30px; font-size: 0.75rem; margin-bottom: 12px; } + +.general-right .pane-bundle-slac-sidebar-block ul li { color: rgb(135, 22, = +40); margin-bottom: 7px !important; } + +.general-right .pane-bundle-slac-sidebar-block ul li a { color: inherit; } + +.general-right .pane-bundle-slac-sidebar-block ul li a:hover { text-decorat= +ion: underline; } + +.pane-bundle-slac-mini-slideshow > div { text-align: center; margin: 0px au= +to; max-width: 315px; position: relative; } + +@media (min-width: 480px) { + .inner_col { } + .inner_col::before, .inner_col::after { content: ""; display: table; } + .inner_col::after { clear: both; } + .inner_col .inner_left { float: left; } + .inner_col .inner_right { float: right; } + .inner_col > div { width: 47%; } +} + +@media (min-width: 690px) { + .inner_col .inner_left, .inner_col .inner_right { float: none; width: 100= +%; } +} + +@media (min-width: 760px) { + .inner_col > div { width: 45%; } + .inner_col .inner_left { float: left; } + .inner_col .inner_right { float: right; } +} + +@media (min-width: 894px) { + .inner_col .inner_left { width: 65%; } + .inner_col .inner_right { width: 30%; } +} + +.service-listing h2 { font-size: 1.25rem; } + +.service-listing h2 a { color: rgb(135, 22, 40); } + +.service-listing p { font-size: 0.875rem; line-height: 20px; } + +@media (min-width: 480px) { + .views-responsive-grid.views-columns-2 .views-column.views-column-1 { wid= +th: 47.8705%; float: left; margin-right: 4.25894%; } + .views-responsive-grid.views-columns-2 .views-column.views-column-2 { wid= +th: 47.8705%; float: right; margin-right: 0px; } +} + +.services-sidebar .views-label { font-weight: bold; margin-bottom: 5px; tex= +t-transform: uppercase; display: inline-block; } + +.services-sidebar .views-field-field-slac-sc-request-link a { padding: 6px = +12px; color: white; background-color: rgb(135, 22, 40); border-radius: 5px;= + } + +.services-sidebar .views-field-field-slac-sc-request-link a:hover { box-sha= +dow: white 0px 0px 4px 1px inset, rgb(135, 22, 40) 0px 0px 0px 1px; text-sh= +adow: black 1px 1px 1px; text-decoration: none; } + +.services-sidebar .view-content .views-field { margin-bottom: 25px; } + +.services-sidebar .view-content ul { list-style-type: disc; margin-left: 15= +px; } + +.a-z-filter .view-content .views-row { margin-bottom: 5px; } + +form.search-form { margin-top: 20px; margin-bottom: 20px; display: inline-b= +lock; width: 100%; } + +form.search-form > div { position: relative; } + +form.search-form .form-item { padding-right: 110px; display: inline-block; = +width: 100%; } + +form.search-form .form-item label { position: absolute; top: -25px; left: 0= +px; } + +form.search-form input[type=3D"submit"] { position: absolute; top: -2px; ri= +ght: 0px; margin: 0px; line-height: 1; padding: 0px 28px; height: 29px; } + +#slac-search-options { width: 100%; } + +#slac-search-options > div { background-color: rgb(238, 238, 238); text-ali= +gn: left; padding: 0px; width: 100%; margin: 0px; border-width: 0px 1px 1px= +; border-style: none solid solid; border-right-color: rgb(161, 161, 161); b= +order-bottom-color: rgb(161, 161, 161); border-left-color: rgb(161, 161, 16= +1); border-image: initial; border-top-color: initial; } + +#slac-search-options > div > div { padding: 2px 6px 5px; } + +#slac-search-options .form-type-radio { margin-right: 5px; } + +#slac-search-options .form-radios > div { display: inline-block; vertical-a= +lign: middle; } + +#slac-search-options .form-item label, #slac-search-options .form-item inpu= +t[type=3D"radio"] { display: inline-block; vertical-align: middle; } + +#slac-search-options .form-radios label, #slac-search-options .form-radios = +input[type=3D"radio"] { margin: 0px; padding: 0px; } + +#slac-search-options .form-radios label { color: rgb(135, 22, 40); font-siz= +e: 0.6875rem; } + +#slac-search-options .form-radios input[type=3D"radio"] { cursor: pointer; = +} + +@media (min-width: 690px) { + #slac-search-options { position: relative; margin: 0px; padding: 0px; } + #slac-search-options > div { display: none; position: absolute; z-index: = +1; top: 0px; left: 0px; } +} + +ol.search-results li { border-bottom: 1px solid rgb(243, 241, 235); margin-= +bottom: 15px; padding-bottom: 15px; } + +ol.search-results li:last-child { border-bottom: 0px; margin-bottom: 0px; p= +adding-bottom: 0px; } + +ol.search-results .search-info { color: rgb(71, 71, 71); text-align: right;= + } + +ol.search-results .search-snippet { font-size: 0.875rem; margin: 5px 0px; } + +ol.search-results .search-snippet strong { font-weight: bold; color: rgb(13= +5, 22, 40); } + +@media (min-width: 690px) { + form.search-form { } + form.search-form::before, form.search-form::after { content: ""; display:= + table; } + form.search-form::after { clear: both; } + form.search-form > div { width: 65.247%; float: left; margin-right: 4.258= +94%; } +} + +@media (min-width: 894px) { + form.search-form > div { width: 56.5588%; float: left; margin-right: 4.25= +894%; } +} + +@media (min-width: 1200px) { + form.search-form > div { width: 39.1823%; float: left; margin-right: 4.25= +894%; } +} + +.event-listing-item { background-color: rgb(238, 238, 238); padding: 14px; = +margin-bottom: 25px; } + +.event-listing-item figure { margin-bottom: 10px; } + +.event-listing-item figure a { border: 3px solid white; display: block; } + +.event-listing-item figure img { display: block; } + +.event-listing-item .field-name-field-slac-event-date { margin-bottom: 5px;= + } + +.event-listing-item h3 { margin-bottom: 10px; } + +.event-listing-item .field-name-field-location { margin-bottom: 10px; } + +.event-listing-item .field-name-body { font-size: 0.75rem; } + +@media (min-width: 385px) { + .event-listing-item figure { max-width: 40%; float: left; margin-right: 1= +5px; } + .event-listing-item h3 { padding-left: 40%; } + .event-listing-item .field-name-field-slac-event-date { clear: left; } + .event-listing-item.no-image h3, .event-listing-item.no-image .field-name= +-field-slac-event-date { padding-left: 0px; margin-left: 0px; } +} + +@media (min-width: 500px) { + .event-listing-item .field-name-field-slac-event-date { clear: none; padd= +ing-left: 40%; margin-left: 15px; } + .event-listing-item .field-name-body { clear: left; } +} + +@media (min-width: 690px) { + .event-listing-item figure { max-width: 102px; } + .event-listing-item h3 { padding-left: 100px; margin-left: 15px; font-siz= +e: 0.875rem; } + .event-listing-item .field-name-field-slac-event-date, .event-listing-ite= +m .field-name-field-location { padding-left: 0px; margin-left: 0px; } + .event-listing-item .field-name-field-slac-event-date { clear: left; } + .event-listing-item .field-name-body { clear: none; } +} + +@media (min-width: 894px) { + .event-listing-item figure { margin-bottom: 0px; } + .event-listing-item h3 { padding-left: 0px; margin-left: 0px; } + .event-listing-item .field-name-field-slac-event-date { clear: none; } + .event-listing-item article { padding-left: 115px; margin-left: 15px; } + .event-listing-item.no-image article { padding-left: 0px; margin-left: 0p= +x; } +} + +.event-listing-panel .view-header h2 { margin-bottom: 15px; font-size: 1.12= +5rem; } + +.event-wrapper { background-color: rgb(238, 238, 238); padding: 14px; } + +.event-wrapper .inner_title { margin-bottom: 21px; } + +.event-wrapper .inner_title h3 { font-size: 1.25rem; color: rgb(51, 51, 51)= +; } + +.event-wrapper .inner_left { margin-bottom: 20px; } + +.event-wrapper .pane-node-field-event-image { margin: 0px -14px 14px; } + +.event-wrapper .pane-node-field-event-image img { display: block; } + +.event-wrapper .pane-bundle-share-block { background-color: rgb(225, 225, 2= +25); margin-bottom: 14px; } + +.event-wrapper .pane-bundle-share-block, .event-wrapper .add-to-calendar { = +padding: 10px !important; } + +.add-to-calendar { background-color: rgb(225, 225, 225); } + +.add-to-calendar a { color: rgb(135, 22, 40); display: inline-block; vertic= +al-align: middle; } + +.add-to-calendar a::before { content: "=EE=98=A7"; color: rgb(153, 153, 153= +); font-size: 1.4375rem; display: inline-block; vertical-align: middle; lin= +e-height: 1; margin-right: 5px; } + +.add-to-calendar a:hover::before { color: rgb(135, 22, 40); } + +@media (min-width: 355px) { + .event-wrapper .pane-node-field-event-image { border: 3px solid white; ma= +rgin-left: 0px; } +} + +@media (min-width: 355px) and (max-width: 893px) { + .event-wrapper .pane-node-field-event-image { margin-bottom: 0px; display= +: block; float: left; width: 47.8% !important; } + .event-wrapper .pane-bundle-share-block, .event-wrapper .add-to-calendar = +{ float: right; width: 47.8% !important; } + .event-wrapper .inner_left { } + .event-wrapper .inner_left::before, .event-wrapper .inner_left::after { c= +ontent: ""; display: table; } + .event-wrapper .inner_left::after { clear: both; } +} + +@media (min-width: 690px) { + .event-wrapper { padding: 20px 30px; } +} + +@media (min-width: 894px) { + .event-wrapper { padding: 20px 40px; } + .event-wrapper-inner { position: relative; } + .event-wrapper-inner::before, .event-wrapper-inner::after { content: ""; = +display: table; } + .event-wrapper-inner::after { clear: both; } + .event-wrapper-inner .inner_left { width: 30.494%; float: left; margin-ri= +ght: -100%; margin-left: 69.506%; } + .event-wrapper-inner .inner_right { width: 65.247%; float: left; margin-r= +ight: 4.25894%; } +} + +.people-page-listing .attachment { margin-bottom: 25px; font-size: 1rem; co= +lor: rgb(122, 111, 79); } + +.people-page-listing .attachment a { color: inherit; } + +.people-page-listing .attachment .no-result, .people-page-listing .attachme= +nt .result { margin-bottom: 10px; display: inline-block; } + +.people-page-listing .attachment .no-result, .people-page-listing .attachme= +nt .result a { padding: 6px; } + +.people-page-listing .attachment .result a { background-color: rgb(243, 241= +, 235); } + +.people-page-listing .attachment .view-header > div { margin-bottom: 10px; = +letter-spacing: 0.05em; } + +.people-page-listing > .view-content { color: rgb(51, 51, 51); font-size: 0= +.75rem; text-align: center; } + +.people-page-listing > .view-content .views-row { background-color: rgb(243= +, 243, 235); margin-bottom: 25px; height: 285px; padding: 20px 30px 0px; di= +splay: inline-block; text-align: left; position: relative; max-width: 230px= +; } + +.people-page-listing > .view-content .views-row:hover { background-color: r= +gb(185, 183, 179); color: rgb(125, 125, 125); } + +.people-page-listing > .view-content h3 { font-size: 0.875rem; } + +.people-page-listing .url-link a { position: absolute; top: 0px; left: 0px;= + width: 100%; height: 100%; display: block; z-index: 9999; text-indent: -99= +99px; } + +.people-page-listing .profile-photo { margin-bottom: 6px; } + +.people-page-listing .profile-photo img { max-width: 100%; } + +@media (min-width: 480px) { + .people-page-listing .view-content > .views-row { max-width: none; } +} + +@media (min-width: 480px) and (max-width: 690px) { + .people-page-listing .view-content .views-row { padding: 25px 35px 0px; w= +idth: 47.8705%; float: left; margin-right: 4.25894%; } + .people-page-listing .view-content .views-row:nth-child(2n) { float: righ= +t; margin-right: 0px; } +} + +@media (min-width: 580px) and (max-width: 690px) { + .people-page-listing > .view-content .views-row { padding: 25px 50px 0px;= + } +} + +@media (min-width: 691px) and (max-width: 893px) { + .people-page-listing .view-content > .views-row { padding: 20px 30px 0px;= + width: 30.494%; float: left; margin-right: 4.25894%; } + .people-page-listing .view-content > .views-row:nth-child(3n) { float: ri= +ght; margin-right: 0px; } +} + +@media (min-width: 690px) { + .people-page-listing .browse-all a { text-decoration: none; } + .people-page-listing .browse-all a:hover { text-decoration: underline; } + .people-page-listing .attachment { font-size: 0.875rem; } + .people-page-listing .attachment .view-content a { text-decoration: under= +line; } + .people-page-listing .attachment .view-content a:hover { text-decoration:= + none; } + .people-page-listing .attachment .no-result, .people-page-listing .attach= +ment .result { margin-bottom: 0px; display: inline; } + .people-page-listing .attachment .no-result, .people-page-listing .attach= +ment .result a { padding: 0px; } + .people-page-listing .attachment .result a { background-color: transparen= +t; } +} + +@media (min-width: 894px) { + .people-page-listing .view-content .views-row { padding: 25px 35px 0px; m= +argin-bottom: 55px; width: 21.8058%; float: left; margin-right: 4.25894%; } + .people-page-listing .view-content .views-row:nth-child(4n) { float: righ= +t; margin-right: 0px; } +} + +.pane-menu-tree > div { background-color: whitesmoke; } + +.pane-menu-tree > div > ul.menu { font-size: 0.875rem; color: rgb(135, 22, = +40); } + +.pane-menu-tree > div > ul.menu > li > ul { font-size: 0.75rem; } + +.pane-menu-tree > div > ul.menu > li > ul li a { padding-left: 28px; } + +.pane-menu-tree > div > ul.menu a { padding: 5px 14px; color: inherit; disp= +lay: block; } + +.pane-menu-tree > div > ul.menu a:hover, .pane-menu-tree > div > ul.menu a.= +active { background-color: rgb(223, 223, 223); text-decoration: none; } + +@media (min-width: 690px) { + .right-sidebar { width: 56.5588%; float: right; margin-right: 0px; } +} + +@media (min-width: 894px) { + .right-sidebar { width: 65.247%; float: right; margin-right: 0px; } +} + +@media (min-width: 1200px) { + .right-sidebar { width: 18.3324%; float: right; margin-right: 0px; } +} + +.article_panel_layout.with-right-sidebar .pane-node-body h2 { font-size: 1.= +25rem; } + +.node-support-ticket .submitted { font-style: italic; color: rgb(135, 135, = +135); font-size: 0.75rem; margin-bottom: 10px; } + +.node-support-ticket .field-type-text-with-summary { margin-bottom: 10px; } + +.node-support-ticket .field-name-field-support-category > div { display: in= +line-block; vertical-align: bottom; } + +.node-support-ticket section h2 { margin-bottom: 10px; } + +.pane-main-menu h2.pane-title a { color: inherit; } + +.pane-main-menu h2.pane-title a:hover { text-decoration: underline; } + +.messages.no-services { background-color: rgb(255, 252, 229); background-im= +age: none; border: 1px solid rgb(238, 221, 85); margin: 6px 0px; padding: 1= +0px; color: rgb(136, 68, 16); } + +.pane-node-field-bp-image img { border: 1px solid rgb(225, 225, 225); paddi= +ng: 4px; } + +.pane-views-exp-wiki-panel-pane-1 { margin-bottom: 20px; } + +.pane-views-exp-wiki-panel-pane-1 .views-widget-filter-combine { margin-bot= +tom: 10px; } + +.pane-views-exp-wiki-panel-pane-1 .views-widget-filter-combine, .pane-views= +-exp-wiki-panel-pane-1 .views-widget-filter-field_slac_wiki_tags_tid { widt= +h: 100%; padding: 0px !important; } + +.pane-views-exp-wiki-panel-pane-1 .views-reset-button, .pane-views-exp-wiki= +-panel-pane-1 .views-submit-button { clear: left; display: inline-block; fl= +oat: none !important; } + +@media (min-width: 480px) { + .pane-views-exp-wiki-panel-pane-1 .views-widget-filter-combine { margin-r= +ight: 4%; margin-bottom: 0px; } + .pane-views-exp-wiki-panel-pane-1 .views-widget-filter-combine, .pane-vie= +ws-exp-wiki-panel-pane-1 .views-widget-filter-field_slac_wiki_tags_tid { wi= +dth: 48%; } +} + +.wiki-filter .item-list { margin-bottom: 15px; } + +.wiki-filter ul { margin-left: 10px; } + +.wiki-filter li { float: left; clear: both; margin-right: 20px; } + +.wiki-search { font-size: 0.875rem; } + +.wiki-search .views-field-title { font-weight: bold; } + +.wiki-search .view-header { font-weight: bold; margin-bottom: 10px; } + +.wiki-search li { margin-bottom: 20px; } + +.wiki-search header { margin-bottom: 10px; } + +.wiki-search h2 { font-size: 1.125rem; } + +.wiki-search .field-type-text-with-summary { margin-bottom: 10px; } + +#imageData #caption { font-weight: normal !important; } + +#imageData #imageDetails { width: 100% !important; } + +#imageDetails p { font-family: Arial, Helvetica, sans-serif; font-size: 0.8= +75rem; line-height: 16px; } + +.lightbox-download-link { margin: 10px 0px; display: block; font-size: 0.62= +5rem; font-weight: bold; color: rgb(135, 22, 40); } + +.lightbox-download-link::after { content: "=EE=98=B0"; margin-left: 5px; fo= +nt-size: 1rem; } + +#bottomNavClose { position: absolute; bottom: 5px; right: 5px; } + +#imageDataContainer { position: relative; } + +.forum-table tr.even, .forum-table tr.odd { background: none !important; } + +.forum-table-superheader { display: none; } + +.pp-image-style-description-left, .pp-image-style-description-right { margi= +n-bottom: 20px; margin-top: 10px; } + +.pp-image-style-description-left img, .pp-image-style-description-right img= + { float: none !important; } + +.pp-image-style-description-description { margin-top: 3px; font-size: 0.75r= +em; color: rgb(103, 103, 103); } + +@media (min-width: 480px) { + .pp-image-style-description-left { float: left; margin-right: 20px; } + .pp-image-style-description-right { float: right; margin-left: 20px; } +} + +.page-basic-io li { } + +.page-basic-io a:hover { color: rgb(12, 87, 145); } + +.page-basic-io .top-wrapper { background: url("../images/header-bg.jpg") ce= +nter center / cover no-repeat; } + +.page-basic-io .top-wrapper .header { margin-top: -20px; padding-bottom: 5p= +x; padding-top: 20px; position: relative; } + +.page-basic-io .top-wrapper .header::before { background-color: rgb(101, 16= +6, 223); bottom: 0px; content: ""; height: 5px; left: 0px; opacity: 0.25; p= +osition: absolute; width: 100%; } + +.page-basic-io .top-wrapper .header .user-search { padding-bottom: 18px; } + +.page-basic-io .top-wrapper .user-search { padding-top: 18px; } + +.page-basic-io .pane-page-logo { margin-top: -20px; position: relative; z-i= +ndex: 2; } + +.page-basic-io .pane-page-logo img { width: auto; } + +.page-basic-io .panel-panel.site-title { margin-top: -11px; } + +.page-basic-io .pane-site-name-abbreviation a, .page-basic-io .pane-page-si= +te-name a { color: rgb(37, 130, 202); font-size: 32px; font-weight: bold; } + +.page-basic-io .pane-site-name-abbreviation a:hover, .page-basic-io .pane-p= +age-site-name a:hover { text-decoration: underline; } + +.page-basic-io .pane-site-name-abbreviation { margin-right: 11px; padding-r= +ight: 0px; } + +.page-basic-io .pane-site-name-abbreviation::after { content: ""; display: = +inline-block; width: 3px; height: 42px; margin-left: 1px; position: relativ= +e; top: 7px; background: rgb(222, 221, 221); } + +.page-basic-io .pane-site-name-abbreviation a { font-size: 36px; } + +.page-basic-io .left-title-wrapper { border: none; padding-bottom: 0px; col= +or: rgb(73, 73, 73); } + +.page-basic-io .general-left-title h2.pane-title { font-size: 1.85rem; } + +.page-basic-io .pane-page-content .general-left .pane-menu-tree h2.panel-ti= +tle, .page-basic-io .pane-page-content .general-left .pane-menu-tree h2.pan= +e-title, .page-basic-io .pane-page-content .general-left-title .pane-menu-t= +ree h2.panel-title, .page-basic-io .pane-page-content .general-left-title .= +pane-menu-tree h2.pane-title, .page-basic-io .pane-page-content .general-ti= +tle .pane-menu-tree h2.panel-title, .page-basic-io .pane-page-content .gene= +ral-title .pane-menu-tree h2.pane-title, .page-basic-io .pane-page-content = +.general-left .pane-menu-tree h2.panel-title, .page-basic-io .pane-page-con= +tent .general-left .pane-menu-tree h2.pane-title, .page-basic-io .pane-page= +-content .general-left-title .pane-menu-tree h2.panel-title, .page-basic-io= + .pane-page-content .general-left-title .pane-menu-tree h2.pane-title, .pag= +e-basic-io .pane-page-content .general-title .pane-menu-tree h2.panel-title= +, .page-basic-io .pane-page-content .general-title .pane-menu-tree h2.pane-= +title { font-size: 18px; line-height: 30px; color: rgb(32, 101, 154); paddi= +ng-bottom: 2px; border-bottom: 2px solid rgb(32, 132, 195); letter-spacing:= + 0.5px; } + +.page-basic-io .pane-page-content .general-left .pane-menu-tree h2.panel-ti= +tle a, .page-basic-io .pane-page-content .general-left .pane-menu-tree h2.p= +ane-title a, .page-basic-io .pane-page-content .general-left-title .pane-me= +nu-tree h2.panel-title a, .page-basic-io .pane-page-content .general-left-t= +itle .pane-menu-tree h2.pane-title a, .page-basic-io .pane-page-content .ge= +neral-title .pane-menu-tree h2.panel-title a, .page-basic-io .pane-page-con= +tent .general-title .pane-menu-tree h2.pane-title a, .page-basic-io .pane-p= +age-content .general-left .pane-menu-tree h2.panel-title a, .page-basic-io = +.pane-page-content .general-left .pane-menu-tree h2.pane-title a, .page-bas= +ic-io .pane-page-content .general-left-title .pane-menu-tree h2.panel-title= + a, .page-basic-io .pane-page-content .general-left-title .pane-menu-tree h= +2.pane-title a, .page-basic-io .pane-page-content .general-title .pane-menu= +-tree h2.panel-title a, .page-basic-io .pane-page-content .general-title .p= +ane-menu-tree h2.pane-title a { color: rgb(32, 101, 154); } + +.page-basic-io .pane-page-content .general-left .pane-menu-tree h2.panel-ti= +tle a:hover, .page-basic-io .pane-page-content .general-left .pane-menu-tre= +e h2.pane-title a:hover, .page-basic-io .pane-page-content .general-left-ti= +tle .pane-menu-tree h2.panel-title a:hover, .page-basic-io .pane-page-conte= +nt .general-left-title .pane-menu-tree h2.pane-title a:hover, .page-basic-i= +o .pane-page-content .general-title .pane-menu-tree h2.panel-title a:hover,= + .page-basic-io .pane-page-content .general-title .pane-menu-tree h2.pane-t= +itle a:hover, .page-basic-io .pane-page-content .general-left .pane-menu-tr= +ee h2.panel-title a:hover, .page-basic-io .pane-page-content .general-left = +.pane-menu-tree h2.pane-title a:hover, .page-basic-io .pane-page-content .g= +eneral-left-title .pane-menu-tree h2.panel-title a:hover, .page-basic-io .p= +ane-page-content .general-left-title .pane-menu-tree h2.pane-title a:hover,= + .page-basic-io .pane-page-content .general-title .pane-menu-tree h2.panel-= +title a:hover, .page-basic-io .pane-page-content .general-title .pane-menu-= +tree h2.pane-title a:hover { text-decoration: underline; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div { background-colo= +r: white; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu { padding-l= +eft: 0px; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li { paddin= +g-left: 0px; margin-left: 0px; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2 = +> a { color: rgb(12, 87, 145); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-active, .page-basic-io .panel-panel.content .pane-menu-tree > div .menu = +li.depth-2.is-active-trail { background: rgb(234, 238, 240); position: rela= +tive; overflow: hidden; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-active::before, .page-basic-io .panel-panel.content .pane-menu-tree > di= +v .menu li.depth-2.is-active-trail::before { content: ""; display: block; p= +osition: absolute; left: 0px; width: 3px; height: 100%; background: rgb(119= +, 169, 217); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2 = +> a.active { background: none; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-expanded { border-bottom: 1px solid rgb(181, 181, 181); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-expanded > a { color: rgb(12, 87, 145); border: none; margin-top: 9px; p= +adding-top: 5px; padding-bottom: 5px; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-expanded > a:hover { background: rgb(220, 226, 229); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-expanded > a.active { background: rgb(220, 226, 229); color: rgb(12, 87,= + 145); font-size: 14px; padding-top: 5px; padding-bottom: 5px; border: none= +; margin-top: 9px; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-2.= +is-expanded > .menu { margin-bottom: 9px; } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-3 = +a { border: none; padding: 3px 14px 3px 44px; font-size: 12px; line-height:= + 26px; color: rgb(32, 101, 154); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li.depth-3 = +a:hover, .page-basic-io .panel-panel.content .pane-menu-tree > div .menu li= +.depth-3 a.active { background: rgb(220, 226, 229); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li a { font= +-size: 14px; line-height: 20px; padding: 12px 12px 12px 16px; border-bottom= +: 1px solid rgb(181, 181, 181); } + +.page-basic-io .panel-panel.content .pane-menu-tree > div .menu li a:hover = +{ background: rgb(234, 238, 240); } + +.page-basic-io .top-menu { min-height: 20px; position: relative; z-index: 1= +; } + +.page-basic-io .top-menu::before { background-color: rgb(67, 121, 142); con= +tent: ""; height: 100%; left: 0px; opacity: 0.4; position: absolute; top: 0= +px; width: 100%; z-index: -1; } + +.page-basic-io .top-menu ul.menu { float: right; } + +.page-basic-io .top-menu ul.menu li { list-style-type: none; list-style-ima= +ge: none; float: left; } + +@media (min-width: 690px) { + .page-basic-io .top-menu ul.menu li { border-right: 1px solid white; } + .page-basic-io .top-menu ul.menu li:first-child { border-left: 1px solid = +white; } +} + +.page-basic-io .top-menu ul.menu a { color: white; padding: 0px 20px; font-= +size: 9px; text-transform: uppercase; line-height: 20px; font-weight: bold;= + letter-spacing: 0.125em; } + +.page-basic-io .top-menu ul.menu a:hover, .page-basic-io .top-menu ul.menu = +a.active { background-color: transparent; } + +.page-basic-io .top-menu ul.menu a:hover { text-decoration: underline; } + +@media (min-width: 690px) { + .page-basic-io .top-menu ul.menu a { padding: 0px 30px; } +} + +@media (max-width: 640px) { + .page-basic-io .top-menu ul.menu { display: none; } +} + +.page-basic-io .pane-node-field-bp-image img { border: 2px solid rgb(225, 2= +25, 225); padding: 0px; } + +.page-basic-io input[type=3D"text"], .page-basic-io input[type=3D"password"= +], .page-basic-io select, .page-basic-io textarea, .page-basic-io input[typ= +e=3D"email"], .page-basic-io input[type=3D"number"] { box-shadow: rgba(0, 0= +, 0, 0.15) 0px 1px 2px inset; } + +.page-basic-io table { width: 100%; max-width: 100%; } + +.page-basic-io .content .services-sidebar .views-field-field-slac-sc-reques= +t-link a { background: rgb(37, 130, 202); color: white; } + +.page-basic-io .content .services-sidebar .views-field-field-slac-sc-reques= +t-link a:hover { box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 5px inset; border:= + none; } + +.page-basic-io .box-about > div.shaded { padding: 20px; background: rgb(231= +, 238, 231); font-size: 16px; line-height: 22px; } + +.page-basic-io .box-about > div.shaded p { margin-bottom: 10px; } + +.page-basic-io .header-menu ul li a { color: rgb(106, 152, 187); border-col= +or: rgb(138, 138, 138); padding: 0px 10px; } + +.page-basic-io .header-menu ul li a:hover { text-decoration: underline; } + +.page-basic-io .footer-seccond { background-color: rgb(244, 244, 244); colo= +r: rgb(95, 95, 96); padding: 16px 0px 12px; } + +.page-basic-io .footer-seccond a { color: rgb(21, 78, 107); } + +.page-basic-io .footer-seccond .general-left { padding-top: 15px; } + +.page-basic-io .footer-seccond .general-right { margin: 12px 0px 0px; } + +.page-basic-io .footer-seccond .general-right a { display: inline-block; ve= +rtical-align: middle; } + +.page-basic-io .pane-page-content .general-left h2.pane-title, .page-basic-= +io .pane-page-content .general-left-title h2.pane-title, .page-basic-io .pa= +ne-page-content .general-title h2.pane-title, .page-basic-io .pane-page-con= +tent .general-left h2.panel-title, .page-basic-io .pane-page-content .gener= +al-left-title h2.panel-title, .page-basic-io .pane-page-content .general-ti= +tle h2.panel-title { border: none; font-size: 30px; color: rgb(73, 73, 73);= + padding-bottom: 2px; } + +.page-basic-io .pane-page-content .general-right, .page-basic-io .pane-page= +-content .general-right .region-grey-background-style, .page-basic-io .pane= +-page-content .pane-bundle-slac-sidebar-block { padding: 0px; background: n= +one; } + +.page-basic-io .pane-page-content .general-right h2.panel-title, .page-basi= +c-io .pane-page-content .general-right h2.pane-title, .page-basic-io .pane-= +page-content .general-right .region-grey-background-style h2.panel-title, .= +page-basic-io .pane-page-content .general-right .region-grey-background-sty= +le h2.pane-title, .page-basic-io .pane-page-content .pane-bundle-slac-sideb= +ar-block h2.panel-title, .page-basic-io .pane-page-content .pane-bundle-sla= +c-sidebar-block h2.pane-title { font-size: 12px; color: rgb(72, 72, 72); li= +ne-height: 17px; margin-bottom: 1px; padding-bottom: 0px; border-bottom: no= +ne; letter-spacing: 1px; } + +.page-basic-io .pane-page-content .general-right > .pane-body-grey-backgrou= +nd-style, .page-basic-io .pane-page-content .general-right .panel-pane > .v= +iew, .page-basic-io .pane-page-content .general-right .a-z-filter, .page-ba= +sic-io .pane-page-content .general-right .view-service-catalog-category.vie= +w-display-id-panel_pane_4, .page-basic-io .pane-page-content .general-right= + .region-grey-background-style > .pane-body-grey-background-style, .page-ba= +sic-io .pane-page-content .general-right .region-grey-background-style .pan= +el-pane > .view, .page-basic-io .pane-page-content .general-right .region-g= +rey-background-style .a-z-filter, .page-basic-io .pane-page-content .genera= +l-right .region-grey-background-style .view-service-catalog-category.view-d= +isplay-id-panel_pane_4, .page-basic-io .pane-page-content .pane-bundle-slac= +-sidebar-block > .pane-body-grey-background-style, .page-basic-io .pane-pag= +e-content .pane-bundle-slac-sidebar-block .panel-pane > .view, .page-basic-= +io .pane-page-content .pane-bundle-slac-sidebar-block .a-z-filter, .page-ba= +sic-io .pane-page-content .pane-bundle-slac-sidebar-block .view-service-cat= +alog-category.view-display-id-panel_pane_4 { padding: 15px 15px 13px; font-= +size: 12px; line-height: 18px; } + +.page-basic-io .pane-page-content div.general-right a { color: rgb(32, 101,= + 154); } + +.page-basic-io .pane-page-content .menu { padding-left: 6px; } + +.page-basic-io .pane-page-content .menu li { margin-left: 16px; } + +.page-basic-io .pane-page-content .menu li a.active-item, .page-basic-io .p= +ane-page-content .menu li a.active-type { position: relative; text-decorati= +on: none; } + +.page-basic-io .pane-page-content .menu li a.active-item::before, .page-bas= +ic-io .pane-page-content .menu li a.active-type::before { content: "=EF=BF= +=BD"; position: absolute; color: rgb(72, 72, 72); font-size: 16px; left: -1= +2px; top: 50%; margin-top: -10px; } + +.page-basic-io .pane-body-grey-background-style { background: rgb(244, 244,= + 244); } + +.page-basic-io .general-right .panel-pane > .view { background: rgb(244, 24= +4, 244); } + +.page-basic-io .pane-page-content .reverse .general-right .panel-pane > .vi= +ew { padding: 0px; background: transparent; } + +.page-basic-io .content .general-right .panel-pane .menu li a.active-item {= + text-decoration: none; } + +.page-basic-io .pane-bundle-slideshow-description-bottom div.field-slidesho= +w-controls a:hover, .page-basic-io .pane-bundle-slac-mini-slideshow div.fie= +ld-slideshow-controls a:hover { color: white; } + +.page-basic-io .field-name-field-slideshow-btmdesc-slide { border: none; } + +.page-basic-io .pane-bundle-slideshow-description-bottom .field-slideshow-b= +ody { background: rgba(0, 0, 0, 0.38); } + +.page-basic-io .pane-bundle-slideshow-description-bottom .field-slideshow-b= +ody .field-slideshow-description a { color: white; } + +.page-basic-io .projects-blocks-wrapper .project-block-wrapper .project-tit= +le a, .page-basic-io .projects-blocks-wrapper .project-block-wrapper .proje= +ct-description a { color: white; } + +.page-basic-io .content .general-right .event-block { padding-left: 55px; m= +argin-bottom: 20px; border-bottom-color: rgb(235, 235, 224); } + +.page-basic-io .content .general-right .event-block .event { padding-right:= + 35px; margin-bottom: 17px; } + +.page-basic-io .content .general-right .event-block a.icon { color: rgb(192= +, 191, 191); } + +.page-basic-io .content .general-right .more-link { margin-bottom: -13px; m= +argin-right: -6px; } + +.page-basic-io .content .general-right .pane-bundle-slac-sidebar-block ul l= +i { color: rgb(102, 102, 102); } + +.page-basic-io .pane-bundle-slac-sidebar-block p { font-size: 0.9rem; line-= +height: 1.5em; margin-bottom: 15px; } + +.page-basic-io .event-listing-item .field-name-body { font-size: 14px; line= +-height: 22px; color: rgb(53, 53, 53); } + +.page-basic-io .event-wrapper { padding: 20px; } + +.page-basic-io .calendar-page table tbody td a.active { color: white; backg= +round: rgb(37, 130, 202); } + +.page-basic-io .faq-list .views-field-title { color: rgb(12, 87, 145); } + +.page-basic-io .basic-format-text p, .page-basic-io article.node-blog.view-= +mode-full .field-type-text-with-summary p, article.node-blog.view-mode-full= + .page-basic-io .field-type-text-with-summary p, .page-basic-io article.nod= +e-blog.view-mode-full .blog-wrapper p, article.node-blog.view-mode-full .pa= +ge-basic-io .blog-wrapper p, .page-basic-io .article_panel_layout .pane-nod= +e-body p, .article_panel_layout .page-basic-io .pane-node-body p, .page-bas= +ic-io .service-body p, .page-basic-io .node-slac-sc-catalog-item p, .page-b= +asic-io .node-support-ticket .field-type-text-with-summary p, .node-support= +-ticket .page-basic-io .field-type-text-with-summary p, .page-basic-io arti= +cle.node-blog.view-mode-full .field-type-text-with-summary p, .page-basic-i= +o article.node-blog.view-mode-full .blog-wrapper p, .page-basic-io .article= +_panel_layout .pane-node-body p, .page-basic-io .service-body p, .page-basi= +c-io .node-slac-sc-catalog-item p, .page-basic-io .node-support-ticket .fie= +ld-type-text-with-summary p { font-size: 14px; line-height: 22px; margin-bo= +ttom: 10px; color: rgb(53, 53, 53); } + +.page-basic-io .share-block-wrapper a:hover, .page-basic-io .add-to-calenda= +r a:hover::before { color: rgb(37, 130, 202) !important; } + +.page-basic-io .pager li { background: rgb(219, 219, 219); } + +.page-basic-io .pager li a { color: rgb(37, 130, 202); } + +.page-basic-io .pager li.pager-current { background: rgb(149, 181, 201); } + +.page-basic-io .pager li.pager-current a { color: rgb(68, 68, 68); } + +.page-basic-io .pager li a:hover { background: rgb(37, 130, 202); color: wh= +ite; } + +.page-basic-io .news-archive-class > li { padding-left: 10px; } + +.page-basic-io .article_panel_layout .field-name-field-slac-news-date { fon= +t-style: normal; font-size: 12px; } + +.page-basic-io .news-landing .field-name-body p, .page-basic-io .kb-article= +s .field-name-body p { font-size: 0.85rem; } + +.page-basic-io .extentions_normal-link, .page-basic-io .content .general-le= +ft a, .content .general-left .page-basic-io a, .page-basic-io .content .gen= +eral-one-col a, .content .general-one-col .page-basic-io a, .page-basic-io = +.content .general-right a, .content .general-right .page-basic-io a, .page-= +basic-io .pane-node-field-slac-event-related-links a, .pane-node-field-slac= +-event-related-links .page-basic-io a, .page-basic-io .content .general-lef= +t a, .page-basic-io .pane-node-field-slac-event-related-links a { color: rg= +b(37, 130, 202); } + +.page-basic-io .news-image img { border: 2px solid rgb(224, 224, 224); } + +.page-basic-io .news-landing .views-row.views-row-last, .page-basic-io .kb-= +articles .views-row.views-row-last { padding-bottom: 20px; border-bottom: 2= +px solid rgb(224, 222, 205); } + +.page-basic-io .view-news-tags .item-list { width: 105%; } + +.page-basic-io .view-news-tags .item-list li { display: inline-block; heigh= +t: 20px; line-height: 20px; margin-right: 19px; margin-bottom: 11px !import= +ant; } + +.page-basic-io .view-news-tags .item-list li a { background: white; display= +: inline-block; padding: 0px 5px; border-radius: 4px; color: rgb(66, 66, 66= +); font-size: 13px; } + +.page-basic-io .view-news-tags .item-list li a:hover { background: rgb(37, = +130, 202); color: white; text-decoration: none; } + +@media (min-width: 690px) { + .page-basic-io .footer-seccond .general-right { margin: 0px; text-align: = +right; } +} + +.page-basic-io article.node-blog.node-teaser header h2 a:hover { background= +-color: rgb(200, 214, 200); } + +.page-basic-io article.node-blog.node-teaser header h2 a:hover::after { col= +or: rgb(34, 132, 195); } + +.page-basic-io .region-grey-background-style .blog-tags ul li a:hover { bac= +kground-color: rgb(12, 87, 145); } + +.html .page-basic-io .pane-bundle-slac-mini-slideshow .field-slideshow-cont= +rols a, .html .page-basic-io .pane-bundle-slideshow-description-bottom .fie= +ld-slideshow-controls a { color: white; } + +.html .page-basic-io .pane-bundle-slac-mini-slideshow .field-slideshow-cont= +rols a:hover, .html .page-basic-io .pane-bundle-slideshow-description-botto= +m .field-slideshow-controls a:hover { color: rgb(37, 130, 202); } + +.front .page-basic-io .pane-page-content .general-left h2.pane-title, .fron= +t .page-basic-io .pane-page-content .general-left-title h2.pane-title, .fro= +nt .page-basic-io .pane-page-content .general-title h2.pane-title, .front .= +page-basic-io .pane-page-content .general-left h2.panel-title, .front .page= +-basic-io .pane-page-content .general-left-title h2.panel-title, .front .pa= +ge-basic-io .pane-page-content .general-title h2.panel-title { border: none= +; margin-bottom: 7px; font-size: 1.1rem; color: rgb(70, 73, 75); } + +.page-basic-io .user-search .chosen-container { padding: 0px; font-size: 10= +px; color: rgb(145, 145, 145); text-transform: uppercase; font-weight: bold= +; display: block; cursor: default; width: 100% !important; } + +.page-basic-io .user-search .chosen-container .chosen-single { color: rgb(1= +45, 145, 145); display: block; padding: 0px 9px; height: 22px; line-height:= + 22px; position: relative; letter-spacing: 1px; } + +.page-basic-io .user-search .chosen-container .chosen-single::after { conte= +nt: ""; width: 22px; height: 22px; position: absolute; right: 0px; top: 0px= +; background: url("../images/chosen-sprite.png") 6px 2px no-repeat; border-= +left: 1px solid rgb(225, 225, 225); } + +.page-basic-io .user-search .chosen-container .chosen-search { display: non= +e; } + +.page-basic-io .user-search .chosen-container .chosen-drop { border-top: 1p= +x solid rgb(225, 225, 225); background: rgb(239, 239, 239); display: none; = +padding: 0px; } + +.page-basic-io .user-search .chosen-container .chosen-drop li { padding: 7p= +x 9px; } + +.page-basic-io .user-search .chosen-container .chosen-drop li.result-select= +ed, .page-basic-io .user-search .chosen-container .chosen-drop li.highlight= +ed { background: rgb(35, 130, 201); color: white; } + +.page-basic-io .user-search .chosen-container.chosen-with-drop .chosen-drop= + { display: block; } + +.bordered-img img { border: 5px solid rgb(243, 241, 235); display: block; } + +.field-type-image img { } + +@media (min-width: 480px) { + .views-field-field-prf-contact-photo { float: left; } + .footer-seccond { background-color: rgb(135, 135, 135); color: white; fon= +t-size: 0.625rem; padding: 16px 0px 7px; } + .footer-seccond ul { border-top: 1px solid rgb(153, 153, 153); border-bot= +tom: 1px solid rgb(153, 153, 153); display: block; padding-top: 8px; paddin= +g-bottom: 8px; } + .footer-seccond ul::before, .footer-seccond ul::after { content: ""; disp= +lay: table; } + .footer-seccond ul::after { clear: both; } + .footer-seccond ul li { float: left; margin-right: 4%; margin-bottom: 0px= +; } + .footer-seccond ul li a { padding: 0px 0px 0px 10px; background: none; } + .footer-seccond ul li a::before { content: "=EE=98=85"; font-family: slac= +; font-weight: normal; font-style: normal; text-indent: 0px; speak: none; p= +osition: absolute; font-size: 0.5rem; left: -1px; top: 2px; } + .footer-seccond ul li a::after { display: none; } + .footer-seccond ul li:last-of-type { margin-right: 0px; } +} + +@media (min-width: 690px) { + .page-basic > .header-menu, .page-basic > .main-menu { display: block; } + .page-basic > .content { margin: 37px 0px; } + .header-menu { text-align: right; font-size: 0.6875em; padding: 9px 0px; = +} + .header-menu ul { display: inline-block; } + .header-menu ul li { float: left; } + .header-menu ul li a { color: rgb(117, 117, 117); padding: 0px 13px; bord= +er-left: 1px solid rgb(117, 117, 117); display: block; text-decoration: non= +e; } + .header-menu ul li a:hover { text-decoration: underline; } + .header-menu ul li:first-child a { border-left: 0px; padding-left: 0px; } + .site-title { margin: 0px 0px 9px; } + .pane-page-site-name { font-size: 2.125em; font-weight: bold; } + .mobile-block { display: none !important; } + .page-basic > .header .header-wrapper { padding: 15px 0px 10px; } + .page-basic-io > .header .header-wrapper { padding-top: 0px; } + .icon-wrapper { display: none !important; } + .pane-system-user-menu { display: inline-block !important; } + .pane-system-user-menu ul li a:hover { text-decoration: underline; } + .general-left, .panel-col-first, .general-left-title { width: 65.247%; fl= +oat: left; margin-right: 4.25894%; } + .full-width .general-left, .full-width .panel-col-first, .full-width .gen= +eral-left-title { width: 100%; float: left; margin-right: 4.25894%; } + .reverse .general-left { width: 39.1823%; float: left; margin-right: 4.25= +894%; } + .reverse .general-left-title { padding-left: 43.4412%; float: none; width= +: auto; margin-right: auto; } + .reverse.full-width .general-left-title { padding-left: 0%; } + .general-right, .panel-col-last { width: 30.494%; float: right; margin-ri= +ght: 0px; } + .reverse .general-right, .reverse .panel-col-last { width: 56.5588%; floa= +t: right; margin-right: 0px; } + .reverse.full-width .general-right, .reverse.full-width .panel-col-last {= + width: 100%; float: left; margin-right: 4.25894%; } + .front .pane-page-content .general-right, .front .pane-page-content .gene= +ral-left { padding-top: 0px; } + .content .general-right .panel-pane { padding: 0px 22px 13px; width: 100%= +; } + .reverse .content .general-right .panel-pane, .reverse .content .general-= +right .panels-ipe-portlet-wrapper { padding: 0px; } + .content .general-right .panel-pane:first-of-type, .content .general-righ= +t .panels-ipe-portlet-wrapper:first-of-type { } + .content .general-right .panel-pane:first-of-type.region-grey-background-= +style, .front .content .general-right .panel-pane:first-of-type, .content .= +general-right .panels-ipe-portlet-wrapper:first-of-type.region-grey-backgro= +und-style, .front .content .general-right .panels-ipe-portlet-wrapper:first= +-of-type { } + .content .general-right .panel-pane.region-grey-background-style, .conten= +t .general-right .panels-ipe-portlet-wrapper.region-grey-background-style {= + padding-top: 13px; } + .reverse .general-right .panel-pane, .reverse .general-right .panels-ipe-= +portlet-wrapper { padding: 0px; } + .footer-seccond { } + .footer-seccond::before, .footer-seccond::after { content: ""; display: t= +able; } + .footer-seccond::after { clear: both; } + .footer-seccond ul { border-bottom: 0px; padding-bottom: 0px; margin-bott= +om: 0px; } + .footer-seccond ul li { margin-right: 3%; } + .views-field-field-prf-contact-photo { width: 188px; } + .pane-page-logo img { width: 158px; } +} + +@media (min-width: 894px) { + .general-left, .panel-col-first, #user-profile-form, .general-left-title = +{ width: 64.7465%; float: left; margin-right: 5.76037%; } + .front .pane-page-content .general-left, .front .pane-page-content .panel= +-col-first, .front .pane-page-content #user-profile-form, .front .pane-page= +-content .general-left-title { width: 64.7465%; float: left; margin-right: = +5.76037%; } + .reverse .general-left, .reverse .panel-col-first, .reverse #user-profile= +-form, .reverse .general-left-title { width: 30.494%; float: left; margin-r= +ight: 4.25894%; } + .reverse .general-left-title { padding-left: 34.753%; float: none; width:= + auto; margin-right: auto; } + .general-right, .panel-col-last { width: 29.4931%; float: right; margin-r= +ight: 0px; } + .front .pane-page-content .general-right, .front .pane-page-content .pane= +l-col-last { width: 29.4931%; float: right; margin-right: 0px; } + .reverse .general-right, .reverse .panel-col-last { width: 65.247%; float= +: right; margin-right: 0px; } + .footer-seccond ul li { margin-right: 8%; } +} + +@media (min-width: 1200px) { + .reverse .general-right, .reverse .panel-col-last { width: 73.9353%; floa= +t: right; margin-right: 0px; } + .with-right-sidebar .general-right, .with-right-sidebar .panel-col-last {= + width: 52.1259%; float: left; margin-right: 1.38045%; } + .general-left, .panel-col-first, #user-profile-form, .general-left-title = +{ width: 64.7465%; float: left; margin-right: 5.76037%; } + .front .pane-page-content .general-left, .front .pane-page-content .panel= +-col-first, .front .pane-page-content #user-profile-form, .front .pane-page= +-content .general-left-title { width: 64.7465%; float: left; margin-right: = +5.76037%; } + .reverse .general-left, .reverse .panel-col-first, .reverse #user-profile= +-form, .reverse .general-left-title { width: 21.8058%; float: left; margin-= +right: 4.25894%; } + .reverse .general-left-title { padding-left: 26.0647%; float: none; width= +: auto; margin-right: auto; } +} + +.contact-information .views-label { font-size: 0.625em; font-weight: bold; = +color: rgb(34, 34, 34); text-transform: uppercase; } + +.contact-information .field-content { font-size: 0.875rem; color: inherit; = +} + +.contact-information .views-field { margin-bottom: 11px; } + +.blog-tags ul li { float: left; margin-right: 23px; margin-bottom: 7px; } + +.blog-tags ul li a { display: block; padding: 2px 8px 4px; border-radius: 3= +px; background-color: rgb(241, 241, 241); color: rgb(112, 97, 97); } + +.blog-tags ul li a:hover { background-color: rgb(135, 22, 40); color: white= +; text-decoration: none !important; } + +.region-grey-background-style .blog-tags ul li a { background-color: white;= + } + +.region-grey-background-style .blog-tags ul li a:hover { color: white; back= +ground-color: rgb(135, 22, 40); } + +.connect-with-me .views-row > div { float: left; margin-right: 17px; margin= +-bottom: 17px; } + +.connect-with-me .views-row > div a { text-indent: -9000px; display: block;= + color: rgb(135, 135, 135); position: relative; width: 36px; height: 36px; = +overflow: hidden; } + +.connect-with-me .views-row > div a::after { position: absolute; top: 50%; = +left: 50%; margin-top: -22px; margin-left: -18px; font-size: 2.1875rem; } + +.connect-with-me .views-row > div a:hover { color: rgb(135, 22, 40); } + +.connect-with-me .views-row > div:last-of-type { margin-right: 0px; } + +.connect-with-me .views-row > div.twitter-link a::after { content: "=EE=98= +=90"; } + +.connect-with-me .views-row > div.facebook a::after { content: "=EE=98=84";= + } + +.connect-with-me .views-row > div.linked-in a::after { content: "=EE=98=82"= +; font-size: 2.5625rem; margin-top: -25px; } + +.connect-with-me .views-row > div.flikr a::after { content: "=EE=98=81"; } + +.connect-with-me .views-row > div.goolge-plus a::after { content: "=EE=98= +=83"; } + +ul.blog-archive-class { padding-left: 5px; } + +ul.blog-archive-class > li a { margin-bottom: 9px; display: block; } + +ul.blog-archive-class > li ul { padding-left: 19px; } + +ul.blog-archive-class > li ul li { margin-bottom: 9px; margin-right: 15px; = +float: left; list-style-type: disc; margin-left: 15px; } + +@media (min-width: 690px) { + ul.blog-archive-class > li ul li { margin-bottom: 9px; margin-right: 0px;= + float: none; } +} + +@media screen and (min-width: 1200px) { + .with-right-sidebar .general-right.expand-full { width: 72.5%; } + .panels-ipe-editing .with-right-sidebar .general-right.expand-full { widt= +h: 52.1259%; } +} + +.expand-hide { display: none; } + +.panels-ipe-editing .expand-hide { display: block; } + +.field-slideshow { overflow: hidden; max-width: 100% !important; max-height= +: 100% !important; } + +.field-slideshow-slide { max-width: 100% !important; height: auto !importan= +t; width: auto !important; } + +.field-slideshow-slide a, .field-slideshow-slide img { max-width: 100%; hei= +ght: auto !important; width: auto !important; } + +.region-content ul.field-slideshow-pager { list-style-type: none; padding: = +0px; overflow: hidden; } + +.jcarousel-clip { overflow: hidden; } + +.field-slideshow-carousel li { opacity: 0.6; } + +.field-slideshow-carousel li.activeSlide { opacity: 1; } + +.field-slideshow-carousel-wrapper .hidden { display: none; visibility: hidd= +en; } + +.field-slideshow-controls .play { display: none; } + +.field-name-field-slideshow-btmdesc-slide { position: relative; width: 100%= +; overflow: hidden; } + +.field-name-field-slideshow-btmdesc-slide .field-slideshow-image img { disp= +lay: block; max-width: 100%; height: auto; } + +.field-name-field-slideshow-btmdesc-slide .field-slideshow-pager { display:= + none; } + +.pane-bundle-slideshow-description-bottom { margin: 0px -13px; } + +.pane-bundle-slideshow-description-bottom h2.pane-title { display: none; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a { dis= +play: block; text-indent: -9000px; overflow: hidden; width: 35px; height: 7= +0px; position: absolute; z-index: 5; bottom: 0px; color: white; text-decora= +tion: none; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a::afte= +r { top: 50%; margin-top: -28px; left: 50%; position: absolute; font-size: = +45px; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a:hover= + { text-decoration: none; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a:focus= + { color: rgb(135, 22, 40); } + +.no-touch .pane-bundle-slideshow-description-bottom .field-slideshow-contro= +ls a:hover { color: rgb(135, 22, 40); } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a.prev = +{ left: 0px; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a.prev:= +:after { content: "=EE=98=89"; margin-left: -10px; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a.next = +{ right: 0px; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-controls a.next:= +:after { content: "=3D"; margin-left: -6px; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-body { padding: = +0px 35px; font-size: 0.75rem; color: white; background-color: rgba(0, 0, 0,= + 0.3); height: 70px; line-height: 70px; } + +.no-rgba .pane-bundle-slideshow-description-bottom .field-slideshow-body { = +background: url("../images/fall_back_3perc.png") 0px 0px repeat; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-body:hover a { b= +order-bottom: 1px dotted; text-decoration: none; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-body a { color: = +inherit; } + +.pane-bundle-slideshow-description-bottom .field-slideshow-description { di= +splay: inline-block; vertical-align: middle; line-height: 14px; } + +@media (min-width: 480px) { + .pane-bundle-slideshow-description-bottom { border: 5px solid rgb(243, 24= +1, 235); margin: auto; } + .pane-bundle-slideshow-description-bottom:hover .field-slideshow-controls= + { display: block; } + .pane-bundle-slideshow-description-bottom .field-slideshow-controls { dis= +play: none; } + .pane-bundle-slideshow-description-bottom .field-slideshow-controls a { t= +op: 50%; background-color: rgba(200, 200, 200, 0.5); border-radius: 10px; z= +-index: 99; margin-top: -40px; width: 30px; height: 55px; } + .no-rgba .pane-bundle-slideshow-description-bottom .field-slideshow-contr= +ols a { background: url("../images/fall_back_50perc_grey.png") 0px 0px repe= +at; } + .pane-bundle-slideshow-description-bottom .field-slideshow-controls a::af= +ter { margin-top: -29px; } + .pane-bundle-slideshow-description-bottom .field-slideshow-controls a:hov= +er { background-color: rgba(200, 200, 200, 0.7); } + .no-rgba .pane-bundle-slideshow-description-bottom .field-slideshow-contr= +ols a:hover { background: url("../images/fall_back_70perc_grey.png") 0px 0p= +x repeat; } + .pane-bundle-slideshow-description-bottom .field-slideshow-controls a.pre= +v { left: 20px; } + .pane-bundle-slideshow-description-bottom .field-slideshow-controls a.nex= +t { right: 20px; } + .pane-bundle-slideshow-description-bottom .field-slideshow-body { positio= +n: absolute; bottom: 0px; height: auto; width: 100%; line-height: 20px; dis= +play: block; padding: 10px 16px; } + .pane-bundle-slideshow-description-bottom .field-slideshow-caption a { co= +lor: white; text-decoration: none; font-size: 17px; line-height: normal; } +} + +@media (min-width: 691px) and (max-width: 893px) { + .pane-bundle-slideshow-description-bottom .field-slideshow-body { font-si= +ze: 0.75rem; } +} + +@media (min-width: 894px) { + .field-name-field-slideshow-btmdesc-slide { border: 5px solid white; } + .pane-bundle-slideshow-description-bottom .field-slideshow-body { font-si= +ze: 0.875rem; } + .pane-bundle-slideshow-description-bottom .field-slideshow-description { = +line-height: 20px; } +} + +@media (min-width: 1200px) { + .pane-bundle-slideshow-description-bottom .field-slideshow-body { padding= +: 20px; font-size: 1rem; } +} + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a::after { top: = +50%; margin-top: -28px; left: 50%; position: absolute; font-size: 45px; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a:focus { color:= + rgb(135, 22, 40); } + +.no-touch .pane-bundle-slac-mini-slideshow .field-slideshow-controls a:hove= +r { color: rgb(135, 22, 40); } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a.prev { left: 1= +0px; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a.prev::after { = +content: "=EE=98=89"; margin-left: -10px; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a.next { right: = +10px; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a.next::after { = +content: "=3D"; margin-left: -6px; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a { top: 50%; ba= +ckground-color: rgba(200, 200, 200, 0.5); border-radius: 10px; margin-top: = +-30px; width: 30px; height: 55px; display: block; text-indent: -9000px; ove= +rflow: hidden; position: absolute; z-index: 5; bottom: 0px; color: white; } + +.no-rgba .pane-bundle-slac-mini-slideshow .field-slideshow-controls a { bac= +kground: url("../images/fall_back_50perc_grey.png") 0px 0px repeat; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a::after { margi= +n-top: -29px; } + +.pane-bundle-slac-mini-slideshow .field-slideshow-controls a:hover { backgr= +ound-color: rgba(200, 200, 200, 0.7); } + +.no-rgba .pane-bundle-slac-mini-slideshow .field-slideshow-controls a:hover= + { background: url("../images/fall_back_70perc_grey.png") 0px 0px repeat; } + +.description_block, form#user-profile-form div.password-suggestions, form#u= +ser-profile-form .wrapped-with-icon .description, form#user-login div.passw= +ord-suggestions, form#user-login .wrapped-with-icon .description, form#user= +-register-form div.password-suggestions, form#user-register-form .wrapped-w= +ith-icon .description, form#user-pass div.password-suggestions, form#user-p= +ass .wrapped-with-icon .description, form#slac-configuration-form div.passw= +ord-suggestions, form#slac-configuration-form .wrapped-with-icon .descripti= +on, form.webform-client-form div.password-suggestions, form.webform-client-= +form .wrapped-with-icon .description, form.node-form div.password-suggestio= +ns, form.node-form .wrapped-with-icon .description, form.comment-form div.p= +assword-suggestions, form.comment-form .wrapped-with-icon .description, for= +m.node-blog-form .description { background-color: rgb(235, 235, 223); paddi= +ng: 16px; margin: 10px 0px; width: 100%; border: 0px none; box-shadow: rgb(= +207, 207, 207) 1px 1px 1px 1px; border-radius: 5px; color: rgb(71, 71, 71);= + font-size: 0.8125rem; } + +.node-webform form.webform-client-form { margin-top: 10px; } + +form#user-profile-form, form#user-login, form#user-register-form, form#user= +-pass, form#slac-configuration-form, form.webform-client-form, form.node-fo= +rm, form.comment-form { background-color: rgb(246, 246, 246); border: 3px s= +olid rgb(236, 236, 236); padding: 10px; } + +form#user-profile-form .confirm-parent, form#user-profile-form .password-pa= +rent, form#user-login .confirm-parent, form#user-login .password-parent, fo= +rm#user-register-form .confirm-parent, form#user-register-form .password-pa= +rent, form#user-pass .confirm-parent, form#user-pass .password-parent, form= +#slac-configuration-form .confirm-parent, form#slac-configuration-form .pas= +sword-parent, form.webform-client-form .confirm-parent, form.webform-client= +-form .password-parent, form.node-form .confirm-parent, form.node-form .pas= +sword-parent, form.comment-form .confirm-parent, form.comment-form .passwor= +d-parent { width: 100%; } + +form#user-profile-form .password-strength, form#user-profile-form div.passw= +ord-confirm, form#user-login .password-strength, form#user-login div.passwo= +rd-confirm, form#user-register-form .password-strength, form#user-register-= +form div.password-confirm, form#user-pass .password-strength, form#user-pas= +s div.password-confirm, form#slac-configuration-form .password-strength, fo= +rm#slac-configuration-form div.password-confirm, form.webform-client-form .= +password-strength, form.webform-client-form div.password-confirm, form.node= +-form .password-strength, form.node-form div.password-confirm, form.comment= +-form .password-strength, form.comment-form div.password-confirm { width: a= +uto; float: none; display: block; margin: 0px 0px 10px; } + +form#user-profile-form .filter-guidelines, form#user-login .filter-guidelin= +es, form#user-register-form .filter-guidelines, form#user-pass .filter-guid= +elines, form#slac-configuration-form .filter-guidelines, form.webform-clien= +t-form .filter-guidelines, form.node-form .filter-guidelines, form.comment-= +form .filter-guidelines { padding: 0px; } + +form#user-profile-form .form-item, form#user-login .form-item, form#user-re= +gister-form .form-item, form#user-pass .form-item, form#slac-configuration-= +form .form-item, form.webform-client-form .form-item, form.node-form .form-= +item, form.comment-form .form-item { margin-bottom: 35px; } + +form#user-profile-form .form-item.form-type-radio, form#user-login .form-it= +em.form-type-radio, form#user-register-form .form-item.form-type-radio, for= +m#user-pass .form-item.form-type-radio, form#slac-configuration-form .form-= +item.form-type-radio, form.webform-client-form .form-item.form-type-radio, = +form.node-form .form-item.form-type-radio, form.comment-form .form-item.for= +m-type-radio { margin-bottom: 10px; margin-right: 10px; display: inline-blo= +ck; } + +form#user-profile-form .form-item.form-type-radio input, form#user-profile-= +form .form-item.form-type-radio label, form#user-login .form-item.form-type= +-radio input, form#user-login .form-item.form-type-radio label, form#user-r= +egister-form .form-item.form-type-radio input, form#user-register-form .for= +m-item.form-type-radio label, form#user-pass .form-item.form-type-radio inp= +ut, form#user-pass .form-item.form-type-radio label, form#slac-configuratio= +n-form .form-item.form-type-radio input, form#slac-configuration-form .form= +-item.form-type-radio label, form.webform-client-form .form-item.form-type-= +radio input, form.webform-client-form .form-item.form-type-radio label, for= +m.node-form .form-item.form-type-radio input, form.node-form .form-item.for= +m-type-radio label, form.comment-form .form-item.form-type-radio input, for= +m.comment-form .form-item.form-type-radio label { margin: 0px; display: inl= +ine-block; } + +form#user-profile-form .form-item.form-type-textarea, form#user-login .form= +-item.form-type-textarea, form#user-register-form .form-item.form-type-text= +area, form#user-pass .form-item.form-type-textarea, form#slac-configuration= +-form .form-item.form-type-textarea, form.webform-client-form .form-item.fo= +rm-type-textarea, form.node-form .form-item.form-type-textarea, form.commen= +t-form .form-item.form-type-textarea { margin-bottom: 10px; } + +form#user-profile-form .form-item.form-type-select, form#user-login .form-i= +tem.form-type-select, form#user-register-form .form-item.form-type-select, = +form#user-pass .form-item.form-type-select, form#slac-configuration-form .f= +orm-item.form-type-select, form.webform-client-form .form-item.form-type-se= +lect, form.node-form .form-item.form-type-select, form.comment-form .form-i= +tem.form-type-select { padding: 0px; } + +form#user-profile-form > div, form#user-login > div, form#user-register-for= +m > div, form#user-pass > div, form#slac-configuration-form > div, form.web= +form-client-form > div, form.node-form > div, form.comment-form > div { max= +-width: 360px; } + +form#user-profile-form legend, form#user-login legend, form#user-register-f= +orm legend, form#user-pass legend, form#slac-configuration-form legend, for= +m.webform-client-form legend, form.node-form legend, form.comment-form lege= +nd { display: none; } + +form.comment-form > div { max-width: none; } + +form.comment-form .container-inline label { display: block; } + +form.comment-form .container-inline .form-type-select { display: inline-blo= +ck; } + +form.comment-form .field-type-file input[type=3D"file"], form.comment-form = +.field-type-file input[type=3D"submit"] { margin-bottom: 15px; } + +form.comment-form .filter-wrapper { padding-top: 0px; } + +form.comment-form .filter-wrapper .form-item, form.comment-form .filter-wra= +pper .filter-guidelines { padding-left: 0px; } + +@media (min-width: 690px) { + form#user-profile-form, form#user-login, form#user-register-form, form#us= +er-pass, form#slac-configuration-form, form.webform-client-form, form.node-= +form, form.comment-form { padding: 26px 38px; } +} + +.wrapped-with-icon { position: relative; padding-right: 35px; } + +.wrapped-with-icon .form-item { margin-bottom: 0px !important; } + +.wrapped-with-icon.form-type-password-confirm .icon-ask { top: 55px; } + +.webform-grid .form-item { margin-bottom: 0px !important; } + +.webform-container-inline .form-item { display: inline-block !important; wi= +dth: auto; } + +.webform-component-managed_file input[type=3D"file"] { margin-bottom: 10px;= + } + +.webform-component-radios .form-radios, .webform-component-radios .form-che= +ckboxes, .webform-component-checkboxes .form-radios, .webform-component-che= +ckboxes .form-checkboxes { margin-top: 10px; } + +.webform-component-radios .form-item, .webform-component-checkboxes .form-i= +tem { margin-bottom: 15px !important; } + +.webform-component-radios .form-item label, .webform-component-checkboxes .= +form-item label { display: inline; } + +input[type=3D"submit"], span.submit, .button { border: 1px solid rgb(206, 2= +06, 206); background-color: rgb(228, 228, 228); border-radius: 5px; color: = +rgb(135, 22, 40); font-size: 0.75rem; font-weight: bold; padding: 9px 28px = +7px; cursor: pointer; margin-right: 10px; display: inline-block; } + +input[type=3D"submit"]:hover, span.submit:hover, input[type=3D"submit"]:foc= +us, span.submit:focus, .button:hover, .button:focus { background-color: rgb= +(243, 241, 235); } + +input[type=3D"text"], input[type=3D"password"], select, textarea, input[typ= +e=3D"email"], input[type=3D"number"] { margin: 0px; border-width: 1px; bord= +er-style: solid; border-color: rgb(195, 195, 195) rgb(195, 195, 195) rgb(22= +1, 221, 221); border-image: initial; box-shadow: rgb(124, 124, 124) 0px -2p= +x 0px 0px; padding: 5px 6px 4px; outline: none 0px; font-family: inherit; f= +ont-size: 0.8125rem; } + +.icon-ask { position: absolute; top: 20px; right: 0px; background-color: rg= +b(228, 228, 228); width: 25px; height: 25px; border-radius: 100%; border: 1= +px solid rgb(206, 206, 206); cursor: pointer; user-select: none; } + +.icon-ask::after { content: "?"; color: rgb(135, 22, 40); font-family: Aria= +l, Helvetica, sans-serif; position: absolute; top: 50%; left: 50%; margin-t= +op: -8px; margin-left: -5px; font-weight: bold; font-size: 0.9375rem; } + +html.js input.form-autocomplete { background-position: 100% 5px !important;= + } + +html.js input.throbbing { background-position: 100% -15px !important; } + +form.node-blog-form > div > div { margin-bottom: 25px; } + +form.node-blog-form .filter-guidelines { clear: none; padding: 0px; } + +form.node-blog-form .filter-wrapper .form-item { float: none; padding: 1.5e= +m 0px 0.5em; } + +form.node-blog-form .fieldset-wrapper label { display: inline-block; } + +form.node-blog-form .fieldset-wrapper input[type=3D"checkbox"] { display: i= +nline-block; } + +form.node-blog-form .vertical-tabs fieldset.vertical-tabs-pane { padding: 1= +em !important; } + +form.node-blog-form ul.vertical-tabs-list { margin: 0px; width: 100%; borde= +r-width: 0px 0px 1px; border-style: none none solid; border-top-color: init= +ial; border-right-color: initial; border-left-color: initial; border-image:= + initial; border-bottom-color: rgb(204, 204, 204); } + +form.node-blog-form ul.vertical-tabs-list li { border: 0px none; } + +form.node-blog-form div.vertical-tabs { margin: 0px 0px 1em; } + +form.node-blog-form .filter-help { float: left; padding: 0px; } + +form.node-blog-form .filter-help a { padding-left: 0px; } + +@media (min-width: 690px) { + form.node-blog-form > div, form.node-form > div { max-width: none; } + form.node-blog-form div.vertical-tabs, form.node-form div.vertical-tabs {= + margin: 0px 0px 20px; } + form.node-blog-form ul.vertical-tabs-list, form.node-form ul.vertical-tab= +s-list { margin: 0px 0px 10px; width: auto; float: none; border-bottom: 0px= + none; } + form.node-blog-form ul.vertical-tabs-list li, form.node-form ul.vertical-= +tabs-list li { border: 1px solid rgb(204, 204, 204); } + form.node-blog-form .filter-help, form.node-form .filter-help { float: ri= +ght; padding: 0px; } +} + +.form-type-checkbox input, .form-type-checkbox label { display: inline; } + +.style-guide ins { text-decoration: none; } + +.style-guide pre { background: yellow; } + +.style-guide .styles { margin-bottom: 25px; } + +.style-guide .styles > h2 { border-bottom: 3px solid; margin-bottom: 15px; = +padding-bottom: 5px; color: rgb(102, 102, 102); } + +.style-guide .styles > p { margin-bottom: 10px; } + +.style-guide .styles.style-1 .wrapper { margin-bottom: 10px; } + +.style-guide .styles.style-1 .wrapper::before, .style-guide .styles.style-1= + .wrapper::after { content: ""; display: table; } + +.style-guide .styles.style-1 .wrapper::after { clear: both; } + +.style-guide .styles.style-1 .wrapper > div { color: white; line-height: 30= +px; background-color: rgb(153, 153, 153); font-size: 0.75rem; text-align: c= +enter; } + +.style-guide .styles.style-1 .wrapper > div.grid1 { width: 4.4293%; float: = +left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid1:nth-child(12n) { float: r= +ight; margin-right: 0px; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid2 { width: 13.1175%; float:= + left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid3 { width: 21.8058%; float:= + left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid4 { width: 30.494%; float: = +left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid5 { width: 39.1823%; float:= + left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid6 { width: 47.8705%; float:= + left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid7 { width: 56.5588%; float:= + left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid8 { width: 65.247%; float: = +left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid9 { width: 73.9353%; float:= + left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid10 { width: 82.6235%; float= +: left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid11 { width: 91.3118%; float= +: left; margin-right: 4.25894%; display: inline; } + +.style-guide .styles.style-1 .wrapper > div.grid12 { width: 100%; float: le= +ft; margin-right: 4.25894%; display: inline; } + +.color-box { padding: 10px 10px 5px; border: 1px solid rgb(153, 153, 153); = +float: left; margin-right: 20px; margin-bottom: 20px; width: 126px; } + +.color-box span { width: 100%; height: 100px; display: block; margin-bottom= +: 5px; } + +.color-box p { font-size: 0.75rem; } + +.icon-box { padding: 10px 10px 5px; border: 1px solid rgb(153, 153, 153); f= +loat: left; margin-right: 20px; margin-bottom: 20px; width: 126px; text-ali= +gn: center; } + +.icon-box span { font-size: 1.875rem; } + +.icon-box p { text-align: left; margin-top: 10px; } +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-Location: https://adfs.slac.stanford.edu/adfs/portal/logo/logo.png?id=2BBE42827BAEB278F1A46E599694533F13EF341DF06685ACDE5B29170FAECBA3 + +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 +RU5ErkJggg== + +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-Location: https://adfs.slac.stanford.edu/adfs/portal/images/stanford-logo.png + +iVBORw0KGgoAAAANSUhEUgAAADcAAAA2CAYAAABjhwHjAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ +bWFnZVJlYWR5ccllPAAAAyFpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdp +bj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6 +eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDE0IDc5LjE1 +MTQ4MSwgMjAxMy8wMy8xMy0xMjowOToxNSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJo +dHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlw +dGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAv +IiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RS +ZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpD +cmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgeG1wTU06SW5zdGFuY2VJ +RD0ieG1wLmlpZDo4MjA5RTYwRUNDN0MxMUUzOTg0OENBQTQzRDlDRDA0MyIgeG1wTU06RG9jdW1l +bnRJRD0ieG1wLmRpZDo4MjA5RTYwRkNDN0MxMUUzOTg0OENBQTQzRDlDRDA0MyI+IDx4bXBNTTpE +ZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjgyMDlFNjBDQ0M3QzExRTM5ODQ4 +Q0FBNDNEOUNEMDQzIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjgyMDlFNjBEQ0M3QzExRTM5 +ODQ4Q0FBNDNEOUNEMDQzIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBt +ZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+2n/73gAADwtJREFUeNrMWjtoXFcavkcOaBvNNnE1U0lW +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 +foD7PwIMAN0+ZWq/U+znAAAAAElFTkSuQmCC + +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk---- +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-Location: https://adfs.slac.stanford.edu/adfs/portal/images/doe-logo.png + +iVBORw0KGgoAAAANSUhEUgAAAJQAAAAsCAYAAACDpZHMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ +bWFnZVJlYWR5ccllPAAAAyFpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdp +bj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6 +eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDE0IDc5LjE1 +MTQ4MSwgMjAxMy8wMy8xMy0xMjowOToxNSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJo +dHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlw +dGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAv +IiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RS +ZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpD +cmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgeG1wTU06SW5zdGFuY2VJ +RD0ieG1wLmlpZDo5QkFDMDJENENDN0MxMUUzOUYxM0VBMTBGMzRCQTVFOSIgeG1wTU06RG9jdW1l +bnRJRD0ieG1wLmRpZDo5QkFDMDJENUNDN0MxMUUzOUYxM0VBMTBGMzRCQTVFOSI+IDx4bXBNTTpE +ZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjlCQUMwMkQyQ0M3QzExRTM5RjEz +RUExMEYzNEJBNUU5IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjlCQUMwMkQzQ0M3QzExRTM5 +RjEzRUExMEYzNEJBNUU5Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBt +ZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+Jm+fkgAADhZJREFUeNrsXD1vGzkTJoO01h+Q2hRKGyBu +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 +AABJRU5ErkJggg== + +------MultipartBoundary--SsPSFFREQ7KfimY4gXOfg7SRZOYwrVzomHNQQvqAXk------ diff --git a/test/jwt_tester.py b/test/jwt_tester.py new file mode 100644 index 000000000..2f04010b2 --- /dev/null +++ b/test/jwt_tester.py @@ -0,0 +1,121 @@ +import argparse +import base64 +import datetime +import json +import random + +import jwt +from flask import Flask, request, jsonify + +# Parse command line arguments +parser = argparse.ArgumentParser(description="JWT Tester") +parser.add_argument('--keys', help='JSON string to replace keys dictionary. Format: \'{"key":"value", ...}\'') + +args = parser.parse_args() + +# Default set of keys (Random Passwords) +keys = { + '1': 'EPICS Fail', + '2': 'EPICS Never Fails', + '3': 'Skepticism Rules', + '4': 'Hope dies last', + '5': 'BSOD', +} + +# Predefined list of usernames and passwords +user_credentials = { + 'george': 'password123', + 'michael': 'securepass', + 'anna': 'mypassword', +} + +# Update the keys dictionary if provided in command line arguments --keys +if args.keys: + keys = json.loads(args.keys) + +# Run app +app = Flask(__name__) + +# Configuration +port = 5000 +base_url = 'http://127.0.0.1:' + str(port) +app_route = '/' +validate_route = '/validate' +encryption_algorithm = 'HS256' +aud = 'Secure PVAccess JWT Testing' +json_token = 'token' +token_duration_minutes = 30 + +allowed_token = None + + +def generate_token(username): + global allowed_token + key_id, secret = random.choice(list(keys.items())) + payload = { + 'iss': base_url + validate_route, + 'kid': key_id, + 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=token_duration_minutes), + 'iat': datetime.datetime.now(datetime.timezone.utc), + 'nbf': datetime.datetime.now(datetime.timezone.utc), + 'sub': f'{username}@epics.org', + 'aud': aud + } + + headers = { + 'kid': key_id, + } + + allowed_token = jwt.encode(payload, secret, algorithm=encryption_algorithm, headers=headers) + return allowed_token + + +@app.route(app_route) +def home(): + user = request.args.get('user') + password_base64 = request.args.get('pass') + + if not user or not password_base64: + return jsonify({'error': 'Missing user or pass parameter'}), 400 + + try: + password = base64.b64decode(password_base64).decode('utf-8') + except Exception as e: + return jsonify({'error': 'Invalid base64 encoding'}), 400 + + if user in user_credentials and user_credentials[user] == password: + token = generate_token(user) + return jsonify({json_token: token}) + else: + return jsonify({'error': 'Invalid username or password'}), 401 + + +@app.route(validate_route, methods=['POST']) +def validate(): + token = request.json[json_token] + + # Decode the token without verifying to get the header fields + unverified_header = jwt.get_unverified_header(token) + + # Get the Key ID (kid) from the header + key_id_from_token = unverified_header['kid'] + + # Use the Key ID to fetch the corresponding secret from the keys dictionary + secret_from_dictionary = keys[key_id_from_token] + + try: + # Then, decode the token again, this time verifying the signature + payload = jwt.decode(token, secret_from_dictionary, algorithms=[encryption_algorithm], audience=aud) + + # Format times + for key in ['exp', 'iat', 'nbf']: + date = datetime.datetime.fromtimestamp(payload[key]) + payload[key] = date.strftime("%A %dth %B %Y %H:%M:%S") + + return jsonify({'valid': True, 'payload': payload}) + except Exception as e: + return jsonify({'valid': False, 'error': str(e)}) + + +if __name__ == "__main__": + app.run(port=port) diff --git a/test/jwtlogintest.cpp b/test/jwtlogintest.cpp new file mode 100644 index 000000000..c1e361e27 --- /dev/null +++ b/test/jwtlogintest.cpp @@ -0,0 +1,358 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Thread safety +std::mutex mtx; +std::condition_variable cv; +std::string data_value = "Initial Value"; // Initial value to be displayed + +size_t write_callback(void *contents, size_t size, size_t nmemb, std::string *s) { + size_t new_length = size * nmemb; + try { + s->append((char *)contents, new_length); + } catch (std::bad_alloc &e) { + return 0; + } + return new_length; +} + +// Helper function to extract POST data from the request +std::unordered_map parse_post_data(const std::string &request) { + std::unordered_map post_data; + auto pos = request.find("\r\n\r\n"); + if (pos != std::string::npos) { + std::string body = request.substr(pos + 4); + std::istringstream body_stream(body); + std::string kv; + while (std::getline(body_stream, kv, '&')) { + auto delimiter_pos = kv.find('='); + if (delimiter_pos != std::string::npos) { + std::string key = kv.substr(0, delimiter_pos); + std::string value = kv.substr(delimiter_pos + 1); + post_data[key] = value; + } + } + } + return post_data; +} + +// Helper function to extract cookies from the request +std::unordered_map parse_cookies(const std::string &request) { + std::unordered_map cookies; + auto pos = request.find("Cookie: "); + if (pos != std::string::npos) { + std::string cookie_str = request.substr(pos + 8); + cookie_str = cookie_str.substr(0, cookie_str.find("\r\n")); + std::istringstream cookie_stream(cookie_str); + std::string kv; + while (std::getline(cookie_stream, kv, ';')) { + auto delimiter_pos = kv.find('='); + if (delimiter_pos != std::string::npos) { + std::string key = kv.substr(0, delimiter_pos); + std::string value = kv.substr(delimiter_pos + 1); + cookies[key] = value; + } + } + } + return cookies; +} + +std::string base64_encode(const std::string &in) { + BIO *bio, *b64; + BUF_MEM *buffer_ptr; + + b64 = BIO_new(BIO_f_base64()); // Create a base64 filter + bio = BIO_new(BIO_s_mem()); // Create a memory BIO + BIO_push(b64, bio); // Chain them + BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL); // No newline breaks + BIO_write(b64, in.data(), in.size()); // Write the input string + BIO_flush(b64); // Ensure all data is written + BIO_get_mem_ptr(b64, &buffer_ptr); // Get the output buffer + + std::string out(buffer_ptr->data, buffer_ptr->length); // Create a string from the buffer + + BIO_free_all(b64); // Free the BIOs + + return out; +} + +// Function to send the new token to another process +void send_token_to_another_process(const std::string &token) { + CURL *curl; + CURLcode res; + + curl_global_init(CURL_GLOBAL_DEFAULT); + curl = curl_easy_init(); + + if (curl) { + std::ostringstream post_fields; + post_fields << "token=" << curl_easy_escape(curl, token.c_str(), token.length()); + + curl_easy_setopt(curl, CURLOPT_URL, "http://127.0.0.1:8080/token"); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_fields.str().c_str()); + + res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + std::cerr << "Token update failed: " << curl_easy_strerror(res) << std::endl; + } + + curl_easy_cleanup(curl); + } + curl_global_cleanup(); +} + +// Function to perform a POST request to login and retrieve the token +std::string retrieve_token(const std::string &username, const std::string &password) { + std::string token; + CURL *curl; + CURLcode res; + curl_global_init(CURL_GLOBAL_DEFAULT); + curl = curl_easy_init(); + + if (curl) { + std::string read_buffer; + std::string encoded_password = base64_encode(password); + std::string url = "http://127.0.0.1:5000/?user=" + username + "&pass=" + encoded_password; + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &read_buffer); + + res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + std::cerr << "curl_easy_perform() failed: " << curl_easy_strerror(res) << std::endl; + } else { + std::size_t pos = read_buffer.find("\"token\":\""); + if (pos != std::string::npos) { + pos += 9; // move past "token":" + std::size_t end_pos = read_buffer.find("\"", pos); + if (end_pos != std::string::npos) { + token = read_buffer.substr(pos, end_pos - pos); + } + } + } + curl_easy_cleanup(curl); + } + curl_global_cleanup(); + + // Send the new token to the other process + if (!token.empty()) { + send_token_to_another_process(token); + } + + return token; +} + +// Function to validate the token by performing a GET request +bool validate_token(const std::string &token) { + CURL *curl; + CURLcode res; + + curl_global_init(CURL_GLOBAL_DEFAULT); + curl = curl_easy_init(); + + bool is_valid = false; + + if (curl) { + struct curl_slist *chunk = nullptr; + std::string header = "Authorization: Bearer " + token; + chunk = curl_slist_append(chunk, header.c_str()); + + curl_easy_setopt(curl, CURLOPT_URL, "http://127.0.0.1:5000/validate"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, chunk); + + res = curl_easy_perform(curl); + + if (res == CURLE_OK) { + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code == 200) { + is_valid = true; + } + } else { + std::cerr << "curl_easy_perform() failed: " << curl_easy_strerror(res) << std::endl; + } + curl_easy_cleanup(curl); + curl_slist_free_all(chunk); + } + curl_global_cleanup(); + + return is_valid; +} + +// HTTP Response builder +std::string build_http_response(const std::string &body, const std::string &content_type = "text/html") { + std::ostringstream oss; + oss << "HTTP/1.1 200 OK\r\n"; + oss << "Content-Type: " << content_type << "\r\n"; + oss << "Content-Length: " << body.size() << "\r\n"; + oss << "Connection: close\r\n"; + oss << "\r\n"; + oss << body; + return oss.str(); +} + +// Function to present the main page +void present_main_page(int port) { + int server_fd, new_socket; + struct sockaddr_in address; + int addrlen = sizeof(address); + + if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { + perror("socket failed"); + exit(EXIT_FAILURE); + } + + int opt = 1; + if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) { + perror("setsockopt"); + close(server_fd); + exit(EXIT_FAILURE); + } + + address.sin_family = AF_INET; + address.sin_addr.s_addr = INADDR_ANY; + address.sin_port = htons(port); + + if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) { + perror("bind failed"); + close(server_fd); + exit(EXIT_FAILURE); + } + if (listen(server_fd, 3) < 0) { + perror("listen"); + close(server_fd); + exit(EXIT_FAILURE); + } + + while (true) { + if ((new_socket = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) { + perror("accept"); + close(server_fd); + exit(EXIT_FAILURE); + } + + char buffer[30000] = {0}; + read(new_socket, buffer, 30000); + std::string request(buffer); + + if (request.find("GET /favicon.ico") != std::string::npos) { + std::string response = build_http_response("", "image/x-icon"); + send(new_socket, response.c_str(), response.size(), 0); + close(new_socket); + continue; + } + + if (request.find("GET / ") != std::string::npos) { + // Present the main page based on the token validity + std::string response_body; + auto cookies = parse_cookies(request); + std::string token = cookies["token"]; + + if (!token.empty() && validate_token(token)) { + response_body = R"( + + +

Authenticated Page

+

Value: )" + std::to_string(42) /* Example value, replace with actual dynamic value */ + R"(

+ + + + )"; + } else { + // Token is invalid or not found, show login form + response_body = R"( + + +
+ Username:
+ Password:
+ +
+ + + )"; + } + + std::string response = build_http_response(response_body); + send(new_socket, response.c_str(), response.size(), 0); + } + else if (request.find("POST / ") != std::string::npos) { + // Parse form data + auto post_data = parse_post_data(request); + std::string username = post_data["username"]; + std::string password = post_data["password"]; + + // Process login and get token + std::string token = retrieve_token(username, password); + + std::string response_body; + if (!token.empty()) { + response_body = R"( + + + +
+ DATA:
+
+ + + )"; + } else { + response_body = "Login failed!"; + } + + std::string response = build_http_response(response_body); + send(new_socket, response.c_str(), response.size(), 0); + } + else { + std::string response = build_http_response("Unhandled request type"); + send(new_socket, response.c_str(), response.size(), 0); + } + + close(new_socket); + } +} + +int main() { + try { + std::thread main_server_thread([]() { present_main_page(8081); }); + main_server_thread.join(); + } catch (const std::exception &e) { + std::cerr << "Error: " << e.what() << std::endl; + } + + return 0; +} diff --git a/test/testcerts.h b/test/testcerts.h new file mode 100644 index 000000000..f52ed2260 --- /dev/null +++ b/test/testcerts.h @@ -0,0 +1,411 @@ +// Created on 01/10/2024. +// + +#ifndef PVXS_TESTCERTS_H_ +#define PVXS_TESTCERTS_H_ + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "certfactory.h" +#include "certstatus.h" +#include "certstatusfactory.h" +#include "certstatusmanager.h" +#include "ownedptr.h" +#include "utilpvt.h" + +using namespace pvxs; +using namespace pvxs::certs; + +#define STATUS_VALID_FOR_MINS 30 +#define STATUS_VALID_FOR_SECS (STATUS_VALID_FOR_MINS * 60) +#define STATUS_VALID_FOR_SHORT_SECS 1 +#define REVOKED_SINCE_MINS (60 * 12) +#define REVOKED_SINCE_SECS (REVOKED_SINCE_MINS * 60) + +#define TEST_FIRST_SERIAL 9876543210 + +#define GET_MONITOR_CERT_STATUS_PV "CERT:STATUS:????????:*" + +/** + * @brief The test certificate file names generated by gen_test_certs + * Use `superserver1` as the Mock PCACMS certificate as it is generated without the certificate status custom extension + */ +#define CA_CERT_FILE "ca.p12" +#define CA_CERT_FILE_PWD "" +#define SUPER_SERVER_CERT_FILE "superserver1.p12" +#define SUPER_SERVER_CERT_FILE_PWD "" +#define INTERMEDIATE_SERVER_CERT_FILE "intermediateCA.p12" +#define INTERMEDIATE_SERVER_CERT_FILE_PWD "" +#define SERVER1_CERT_FILE "/Users/george/Projects/com/osprey-dcs/pvxs/test/O.darwin-aarch64/server1.p12" +#define SERVER1_CERT_FILE_PWD "" +#define SERVER2_CERT_FILE "server2.p12" +#define SERVER2_CERT_FILE_PWD "" +#define IOC1_CERT_FILE "ioc1.p12" +#define IOC1_CERT_FILE_PWD "" +#define CLIENT1_CERT_FILE "client1.p12" +#define CLIENT1_CERT_FILE_PWD "" +#define CLIENT2_CERT_FILE "client2.p12" +#define CLIENT2_CERT_FILE_PWD "oraclesucks" + +#define WHO_AM_I_PV "whoami" +#define TLS_METHOD_STRING "x509" +#define TCP_METHOD_STRING "ca" +#define ANON_METHOD_STRING "anonymous" + +#define CERT_CN_SERVER1 "server1" +#define CERT_CN_SERVER2 "server2" +#define CERT_CN_IOC1 "ioc1" +#define CERT_CN_CLIENT1 "client1" +#define CERT_CN_CLIENT2 "client2" + +#define TEST_PV "TESTPV" +#define TEST_PV1 "TESTPV1" +#define TEST_PV2 "TESTPV2" +#define TEST_PV_FIELD "value" + +/** + * @brief Generate the member variables required to store the named certificate + * @code + * DEFINE_MEMBERS(ca) + * @endcode + * @param LNAME lowercase name of the certificate + */ +#define DEFINE_MEMBERS(LNAME) \ + const TestCert LNAME##_cert; \ + Value LNAME##_status_response_value{status_value_prototype}; \ + std::string LNAME##_status_pv_name; \ + PVACertificateStatus LNAME##_cert_status; \ + std::vector LNAME##_cert_statuses; \ + int LNAME##_cert_status_response_counter{0}; + +/** + * @brief set the appropriate member variable with the name of the PV to get status for a specific certificate + * @code + * SET_PV(ca) + * @endcode + * @param LNAME lowercase name of the certificate + */ +#define SET_PV(LNAME) LNAME##_status_pv_name = CertStatusManager::getStatusPvFromCert(LNAME##_cert.cert); + +/** + * @brief Generates initializer code fragment for the appropriate certificate structure from the certificate file(s) + * @code + * Tester() + * : now(time(nullptr)) + * , status_valid_until_time(now.t + STATUS_VALID_FOR_SECS) + * , revocation_date(now.t - REVOKED_SINCE_SECS) + * + * INIT_CERT_MEMBER_FROM_FILE(ca,CA) + * { ... + * @endcode + * @param LNAME lowercase name of the certificate + * @param UNAME uppercase name of the certificate + */ +#define INIT_CERT_MEMBER_FROM_FILE(LNAME, UNAME) , LNAME##_cert(getTestCert(UNAME##_CERT_FILE, UNAME##_CERT_FILE_PWD)) + +/** + * @brief Generates the appropriate code fragment that tests whether the certificate member has been successfully initialized + * @code + * if (CHECK_CERT_MEMBER_CONDITION(ca)) { ... + * @endcode + * @param LNAME lowercase name of the certificate + */ +#define CHECK_CERT_MEMBER_CONDITION(LNAME) !LNAME##_cert.cert || !LNAME##_cert.pkey + +/** + * @brief Generates a switch case statement to handle posting of a response value in the mock PVACMS + * for a request for status for the given certificate + * @code + * if (status_pv.isOpen(pv_name)) { + * switch (serial) { + * POST_VALUE_CASE(ca,post) + * POST_VALUE_CASE(super_server,post) + * default: + * testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + * } + * } else { + * switch (serial) { + * POST_VALUE_CASE(ca,open) + * POST_VALUE_CASE(super_server,open) + * default: + * testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + * } + * } + * @endcode + * @param LNAME lowercase name of the certificate + * @param ACTION post or open depending on whether this is the initial value or subsequent values + */ +#define POST_VALUE_CASE(LNAME, ACTION) \ + case LNAME##_serial: \ + LNAME##_status_response_value.mark(); \ + pv.ACTION(pv_name, LNAME##_status_response_value); \ + LNAME##_cert_status_response_counter++; \ + testOk(1, "PV: %s / RESPONSE: %d / VALUE: %s", pv_name.c_str(), \ + LNAME##_cert_status_response_counter, LNAME##_cert_status.status.s.c_str()); \ + { \ + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); \ + MAKE_STATUS_RESPONSE(LNAME) \ + } \ + break; + +/** + * @brief reset the counter for the given status response + * @param LNAME the name of the counter to reset + */ +#define RESET_COUNTER(LNAME) \ + LNAME##_cert_status_response_counter=0; + +/** + * @brief Test that the counter value is correct + * @param LNAME the name of the counter to test + * @param VAL the expected value + */ +#define TEST_COUNTER_EQ(LNAME, VAL) \ + testEq(LNAME##_cert_status_response_counter, VAL); + +/** + * @brief Generates the code fragment that will set the member variable holding the certificate status to be + * returned for status requests for the given certificate from the Mock PVACMS server + * @param LNAME lowercase name of the certificate + * @param STATUSES the statuses to set the member variable to. + * each status is one of `UNKNOWN`, `PENDING_APPROVAL`, `PENDING`, `VALID`, `EXPIRED` or `REVOKED` + * and the statuses are in the order they should be returned for the certificate each time + * the status is requested + */ +#define CREATE_CERT_STATUS(LNAME, STATUSES) \ + try { \ + testDiag("Creating OCSP " #STATUSES " status from: %s", #LNAME " certificate"); \ + LNAME##_cert_statuses = STATUSES; \ + auto _s = LNAME##_cert_statuses[0]; \ + if (_s == REVOKED) \ + LNAME##_cert_status = cert_status_creator.createPVACertificateStatus(LNAME##_cert.cert, _s, now, revocation_date.t); \ + else \ + LNAME##_cert_status = cert_status_creator.createPVACertificateStatus(LNAME##_cert.cert, _s, now); \ + testOk(1, "Created initial OCSP %s status from: %s", ((PVACertStatus)_s).s.c_str(), #LNAME " certificate"); \ + } catch (std::exception & e) { \ + testFail("Failed to create " #STATUSES " status: %s\n", e.what()); \ + } + +/** + * @brief Generates the code fragment that will make a status response and set the appropriate member variable + * based on the given certificate. It will also test that the status value that it can be converted into + * matches the expected one + * @param LNAME lowercase name of the certificate + */ +#define MAKE_STATUS_RESPONSE(LNAME) \ + try { \ + LNAME##_status_response_value = CertStatus::getStatusPrototype().cloneEmpty(); \ + LNAME##_status_response_value.unmark(); \ + setValue(LNAME##_status_response_value, "serial", LNAME##_serial); \ + setValue(LNAME##_status_response_value, "status.value.index", LNAME##_cert_status.status.i); \ + setValue(LNAME##_status_response_value, "status.timeStamp.secondsPastEpoch", time(nullptr)); \ + setValue(LNAME##_status_response_value, "state", LNAME##_cert_status.status.s); \ + setValue(LNAME##_status_response_value, "ocsp_status.value.index", LNAME##_cert_status.ocsp_status.i); \ + setValue(LNAME##_status_response_value, "ocsp_status.timeStamp.secondsPastEpoch", time(nullptr)); \ + setValue(LNAME##_status_response_value, "ocsp_state", SB() << "**UNCERTIFIED**: " << LNAME##_cert_status.ocsp_status.s); \ + \ + if (!LNAME##_cert_status.ocsp_bytes.empty()) { \ + setValue(LNAME##_status_response_value, "ocsp_status.value.index", LNAME##_cert_status.ocsp_status.i); \ + setValue(LNAME##_status_response_value, "ocsp_state", LNAME##_cert_status.ocsp_status.s); \ + setValue(LNAME##_status_response_value, "ocsp_status_date", LNAME##_cert_status.status_date.s); \ + setValue(LNAME##_status_response_value, "ocsp_certified_until", LNAME##_cert_status.status_valid_until_date.s); \ + setValue(LNAME##_status_response_value, "ocsp_revocation_date", LNAME##_cert_status.revocation_date.s); \ + auto ocsp_bytes = shared_array(LNAME##_cert_status.ocsp_bytes.begin(), LNAME##_cert_status.ocsp_bytes.end()); \ + LNAME##_status_response_value["ocsp_response"] = ocsp_bytes.freeze(); \ + } \ + testDiag("Set up: %s", #LNAME " certificate Status Response"); \ + \ + auto converted_response = PVACertificateStatus(LNAME##_status_response_value); \ + testOk1(converted_response == LNAME##_cert_status); \ + testEq(converted_response.ocsp_bytes.size(), LNAME##_cert_status.ocsp_bytes.size()); \ + \ + if (!LNAME##_cert_statuses.empty()) { \ + LNAME##_cert_statuses.erase(LNAME##_cert_statuses.begin()); \ + if (!LNAME##_cert_statuses.empty()) { \ + auto _s = LNAME##_cert_statuses[0]; \ + if (_s == REVOKED) \ + LNAME##_cert_status = cert_status_creator.createPVACertificateStatus(LNAME##_cert.cert, _s, now, revocation_date.t); \ + else \ + LNAME##_cert_status = cert_status_creator.createPVACertificateStatus(LNAME##_cert.cert, _s, now); \ + } \ + } \ + } catch (std::exception & e) { \ + testFail("Failed to setup " #LNAME " status response: %s", e.what()); \ + } + +#define TEST_STATUS_REQUEST(LNAME) \ + try { \ + testDiag("Sending: %s", "Server Status Request"); \ + auto result = client.get(LNAME##_status_pv_name).exec()->wait(5.0); \ + auto LNAME##_status_response = PVACertificateStatus(result); \ + testOk1(LNAME##_status_response == LNAME##_cert_status); \ + testOk1(LNAME##_status_response == LNAME##_cert_status); \ + testOk1((CertifiedCertificateStatus)LNAME##_status_response == LNAME##_cert_status); \ + testOk1((CertifiedCertificateStatus)LNAME##_status_response == (CertifiedCertificateStatus)LNAME##_cert_status); \ + testOk1(LNAME##_status_response == (CertifiedCertificateStatus)LNAME##_cert_status); \ + if (LNAME##_cert_status.status == VALID || LNAME##_cert_status.status == REVOKED) \ + testOk1((OCSPStatus)LNAME##_status_response == LNAME##_cert_status); \ + else \ + testOk1(((OCSPStatus)LNAME##_status_response).ocsp_status == OCSP_CERTSTATUS_UNKNOWN); \ + testOk1((OCSPStatus)LNAME##_status_response == (OCSPStatus)LNAME##_cert_status); \ + if (LNAME##_cert_status.status == VALID || LNAME##_cert_status.status == REVOKED) \ + testOk1(LNAME##_status_response == (OCSPStatus)LNAME##_cert_status); \ + else \ + testOk1(((OCSPStatus)LNAME##_cert_status).ocsp_status == OCSP_CERTSTATUS_UNKNOWN); \ + testDiag("Successfully Received: %s", "Server Status Response"); \ + } catch (std::exception & e) { \ + testFail("Failed to send Server Status Request: %s", e.what()); \ + } + +/** + * @brief Certificate serial numbers used by test system and generated by gen_test_certs + */ +constexpr uint64_t ca_serial = TEST_FIRST_SERIAL; +constexpr uint64_t super_server_serial = ca_serial + 1; +constexpr uint64_t intermediate_server_serial = ca_serial + 2; +constexpr uint64_t server1_serial = ca_serial + 3; +constexpr uint64_t server2_serial = ca_serial + 4; +constexpr uint64_t ioc_serial = ca_serial + 5; +constexpr uint64_t client1_serial = ca_serial + 6; +constexpr uint64_t client2_serial = ca_serial + 7; + +/** + * @class TestCert + * @brief The TestCert class encapsulates a certificate, its chain, and a private key. + * + * This structure holds an X509 certificate, a chain of X509 certificates, and an EVP_PKEY + * private key object. It utilizes our ossl shared and unique pointers for memory management. + * + * @var ossl_ptr TestCert::cert + * X509 certificate encapsulated by this TestCert. + * + * @var ossl_shared_ptr TestCert::chain + * Chain of X509 certificates associated with this TestCert. + * + * @var ossl_ptr TestCert::pkey + * Private key corresponding to the certificate. + * + * @fn TestCert::TestCert(ossl_ptr cert, ossl_shared_ptr chain, ossl_ptr pkey) + * @brief Constructs a new TestCert object. + * @param cert X509 certificate to initialize the TestCert. + * @param chain Chain of X509 certificates to initialize the TestCert. + * @param pkey Private key to initialize the TestCert. + */ +struct TestCert { + ossl_ptr cert; + ossl_shared_ptr chain; + ossl_ptr pkey; + + TestCert(ossl_ptr cert, ossl_shared_ptr chain, ossl_ptr pkey) + : cert(std::move(cert)), chain(std::move(chain)), pkey(std::move(pkey)) {} +}; + +/** + * Sets the value of a specified field in a Value object. + * + * This function compares the current value of the field with the provided + * source value. If they are equal, it calls the 'unmark' method on the field + * to indicate no change is needed. If they are not equal, it updates the field + * with the new source value. + * + * @tparam T The type of the value being set. + * @param target The Value object containing the field to be updated. + * @param field The name of the field within the target object to be updated. + * @param source The new value to be set in the specified field. + */ +template +void setValue(Value &target, const std::string &field, const T &source) { + auto current = target[field]; + if (current.as() == source) { + target[field].unmark(); // Assuming unmark is a valid method for indicating no change needed + } else { + target[field] = source; + } +} + +/** + * Retrieves a test certificate from a given file and password. + * + * This function opens and reads a certificate file in PKCS#12 format, parses it, + * and returns a TestCert object containing the certificate, private key, and + * certificate chain. + * + * @param filename The path to the PKCS#12 certificate file. + * @param password The password to decrypt the PKCS#12 certificate file. + * @return A TestCert object that holds the parsed certificate, private key, and certificate chain. + * + * @error If there is any error opening the file, reading the file, or parsing the PKCS#12 object, + * a TestCert object with nullptr values is returned and diagnostic messages are logged. + */ +TestCert getTestCert(std::string filename, std::string password) { + char buffer[PATH_MAX]; + getcwd(buffer, sizeof(buffer)); + + testDiag("Opening %s certs file", filename.c_str()); + file_ptr fp(fopen(filename.c_str(), "rb"), false); + if (!fp) { + testFail("Error opening certs file for reading binary contents: %s", filename.c_str()); + return TestCert(nullptr, nullptr, nullptr); + } + + testDiag("Opening %s certs file as a PKCS#12 object", filename.c_str()); + ossl_ptr p12(d2i_PKCS12_fp(fp.get(), NULL)); + if (!p12) { + testFail("Error opening certs file as a PKCS#12 object: %s", filename.c_str()); + return TestCert(nullptr, nullptr, nullptr); + } + + ossl_ptr cert; + ossl_ptr pkey; + ossl_shared_ptr chain; + STACK_OF(X509) *chain_ptr = nullptr; + testDiag("Parsing PKCS#12 object to get certificate, key and chain"); + if (!PKCS12_parse(p12.get(), password.c_str(), pkey.acquire(), cert.acquire(), &chain_ptr)) { + testFail("Error Parsing PKCS#12 object: %s", filename.c_str()); + return TestCert(nullptr, nullptr, nullptr); + } + + testTrue(cert.get()); + testTrue(pkey.get()); + + if (!cert || !pkey) { + testFail("Error loading certificate: %s", filename.c_str()); + return TestCert(nullptr, nullptr, nullptr); + } + + if (chain_ptr) { + chain = ossl_shared_ptr(chain_ptr); + testDiag("Acquired %d element Certificate Chain from: %s", sk_X509_num(chain.get()), filename.c_str()); + } else { + chain = ossl_shared_ptr(sk_X509_new_null()); + } + + if (filename == CA_CERT_FILE) + testEq(sk_X509_num(chain.get()), 0); + else if (filename == SUPER_SERVER_CERT_FILE || filename == INTERMEDIATE_SERVER_CERT_FILE) + testEq(sk_X509_num(chain.get()), 1); + else + testEq(sk_X509_num(chain.get()), 2); + + // Test issuer load + X509_NAME *subject_name = X509_get_subject_name(cert.get()); + ossl_ptr name(X509_NAME_oneline(subject_name, nullptr, 0), false); + testTrue(name.get()); + testDiag("Subject of %s: %s", filename.c_str(), name.get()); + + testOk(1, "Loaded certificate from: %s", filename.c_str()); + return TestCert(std::move(cert), std::move(chain), std::move(pkey)); +} + +#endif // PVXS_TESTCERTS_H_ diff --git a/test/testioc.acf b/test/testioc.acf index c5c09de14..a3cec2c87 100644 --- a/test/testioc.acf +++ b/test/testioc.acf @@ -1,17 +1,22 @@ -UAG(MYSELF) { - "$(user)" +UAG(DEFAULT) { + "george" } -ASG(DEFAULT) { - RULE(1,WRITE,TRAPWRITE) +UAG(BAR) { + "michael" +} + +ASG(RO) { + RULE(1,READ,TRAPWRITE) } ASG(SPECIAL) { RULE(1,WRITE,TRAPWRITE) { - UAG(MYSELF) + UAG(DEFAULT) + UAG(BAR) + AUTHORITY("EPICS Root CA") +# AUTHORITY("CA Beamline") +# METHOD("ca") + METHOD("x509") } } - -ASG(RO) { - RULE(1, READ) -} diff --git a/test/testioc.acf.yaml b/test/testioc.acf.yaml new file mode 100644 index 000000000..7caebddff --- /dev/null +++ b/test/testioc.acf.yaml @@ -0,0 +1,92 @@ +# EPICS YAML +version: 1.0 +$schema: https://json-schema.org/draft/2020-12/schema +# yaml-language-server: $schema=../../epics-base/modules/libcom/src/as/epics-access-security-schema.yaml + +# user access groups +uags: + - name: bar + users: + - boss + - name: foo + users: + - testing + - name: ops + users: + - geek + +# host access groups +hags: + - name: local + hosts: + - 127.0.0.1 + - localhost + - 192.168.0.11 + - name: admin + hosts: + - admin.intranet.com + +# Access security group definitions +asgs: + # no access by default + - name: DEFAULT + rules: + - level: 0 + access: NONE + trapwrite: false + + # read only access for non-secure connections for foo and ops + - name: ro + rules: + - level: 0 + access: NONE + trapwrite: false + - level: 1 + access: READ + trapwrite: false + uags: + - foo + - ops + methods: + - ca + + # read write access for foo with a secure connection authenticated by Epics Org CA + - name: rw + links: + - INPA: ACC-CT{}Prmt:Remote-Sel + - INPB: ACC-CT{}Prmt:Remote-Sel + rules: + - level: 0 + access: NONE + trapwrite: false + - level: 1 + access: WRITE + trapwrite: true + calc: VAL>=0 + uags: + - foo + methods: + - x509 + authorities: + - Epics Org CA + + # RPC access for localhost user bar with a secure EPICS Org CA authenticated connection + - name: rwx + rules: + - level: 0 + access: NONE + trapwrite: false + - level: 1 + access: RPC + trapwrite: true + uags: + - bar + hags: + - local + methods: + - x509 + - ignored + - ignored_too + authorities: + - Epics Org CA + - ORNL Org CA diff --git a/test/testnamesrv.cpp b/test/testnamesrv.cpp index 29a240746..ecadfcd59 100644 --- a/test/testnamesrv.cpp +++ b/test/testnamesrv.cpp @@ -39,7 +39,7 @@ void testNameServer() auto cliconf(serv.clientConfig()); for(auto& addr : cliconf.addressList) - cliconf.nameServers.push_back(SB()< + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "utilpvt.h" + +using namespace pvxs; + +namespace { + +void testClientBackwardsCompatibility() { + testShow() << __func__; + + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = "superserver1.p12"; + + auto serv(serv_conf.build().addPV("mailbox", mbox)); + + auto cli_conf(serv.clientConfig()); + + auto cli(cli_conf.build()); + + mbox.open(initial.update("value", 42)); + serv.start(); + + auto conn(cli.connect("mailbox").onConnect([](const client::Connected& c) { testTrue(c.cred && !c.cred->isTLS); }).exec()); + + auto reply(cli.get("mailbox").exec()->wait(5.0)); + testEq(reply["value"].as(), 42); + conn.reset(); +} + +void testServerBackwardsCompatibility() { + testShow() << __func__; + + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + + auto serv(serv_conf.build().addPV("mailbox", mbox)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = "client1.p12"; + + auto cli(cli_conf.build()); + + mbox.open(initial.update("value", 42)); + serv.start(); + + auto conn(cli.connect("mailbox").onConnect([](const client::Connected& c) { testTrue(c.cred && !c.cred->isTLS); }).exec()); + + auto reply(cli.get("mailbox").exec()->wait(5.0)); + testEq(reply["value"].as(), 42); + conn.reset(); +} + +void testGetSuper() { + testShow() << __func__; + + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = "superserver1.p12"; + + auto serv(serv_conf.build().addPV("mailbox", mbox)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = "client1.p12"; + + auto cli(cli_conf.build()); + + mbox.open(initial.update("value", 42)); + serv.start(); + + auto conn(cli.connect("mailbox").onConnect([](const client::Connected& c) { testTrue(c.cred && c.cred->isTLS); }).exec()); + + auto reply(cli.get("mailbox").exec()->wait(5.0)); + testEq(reply["value"].as(), 42); + conn.reset(); +} + +void testGetIntermediate() { + testShow() << __func__; + + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = "server1.p12"; + + auto serv(serv_conf.build().addPV("mailbox", mbox)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = "client1.p12"; + + auto cli(cli_conf.build()); + + mbox.open(initial.update("value", 42)); + serv.start(); + + auto conn(cli.connect("mailbox").onConnect([](const client::Connected& c) { testTrue(c.cred && c.cred->isTLS); }).exec()); + + auto reply(cli.get("mailbox").exec()->wait(5.0)); + testEq(reply["value"].as(), 42); + conn.reset(); +} + +void testGetNameServer() { + testShow() << __func__; + + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = "server1.p12"; + + auto serv(serv_conf.build().addPV("mailbox", mbox)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = "client1.p12"; + + for (auto& addr : cli_conf.addressList) cli_conf.nameServers.push_back(SB() << "pvas://" << addr /*<<':'<isTLS); }).exec()); + + auto reply(cli.get("mailbox").exec()->wait(5.0)); + testEq(reply["value"].as(), 42); +} + +struct WhoAmI final : public server::Source { + const Value resultType; + + WhoAmI() : resultType(nt::NTScalar(TypeCode::String).create()) {} + + virtual void onSearch(Search& op) override final { + for (auto& pv : op) { + if (strcmp(pv.name(), "whoami") == 0) pv.claim(); + } + } + + virtual void onCreate(std::unique_ptr&& op) override final { + if (op->name() != "whoami") return; + + op->onOp([this](std::unique_ptr&& cop) { + cop->onGet([this](std::unique_ptr&& eop) { + auto cred(eop->credentials()); + std::ostringstream strm; + strm << cred->method << '/' << cred->account; + + eop->reply(resultType.cloneEmpty().update("value", strm.str())); + }); + + cop->connect(resultType); + }); + + std::shared_ptr sub; + op->onSubscribe([this, sub](std::unique_ptr&& sop) mutable { + sub = sop->connect(resultType); + auto cred(sub->credentials()); + std::ostringstream strm; + strm << cred->method << '/' << cred->account; + + sub->post(resultType.cloneEmpty().update("value", strm.str())); + }); + } +}; + +Value pop(const std::shared_ptr& sub, epicsEvent& evt) { + while (true) { + if (auto ret = sub->pop()) { + return ret; + + } else if (!evt.wait(5.0)) { + testFail("timeout waiting for event"); + return Value(); + } + } +} + +void testClientReconfig() { + testShow() << __func__; + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = "ioc1.p12"; + + auto serv(serv_conf.build().addSource("whoami", std::make_shared())); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = "client1.p12"; + + auto cli(cli_conf.build()); + + serv.start(); + + epicsEvent evt; + auto sub(cli.monitor("whoami").maskConnected(false).maskDisconnected(false).event([&evt](client::Subscription&) { evt.signal(); }).exec()); + Value update; + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, "x509"); + testEq(e.cred->account, "ioc1"); + } + testDiag("Connect"); + + update = pop(sub, evt); + testEq(update["value"].as(), "x509/client1"); + + cli_conf = cli.config(); + cli_conf.tls_cert_filename = "client2.p12"; + cli_conf.tls_cert_password = "oraclesucks"; + testDiag("cli.reconfigure()"); + cli.reconfigure(cli_conf); + + testThrows([&sub, &evt] { pop(sub, evt); }); + testDiag("Disconnect"); + + try { + (void)pop(sub, evt); + testFail("Missing expected Connected"); + } catch (client::Connected& e) { + testOk1(e.cred && e.cred->isTLS); + } catch (...) { + testFail("Unexpected exception instead of Connected"); + } + testDiag("Reconnect"); + + update = pop(sub, evt); + testEq(update["value"].as(), "x509/client2"); +} + +void testServerReconfig() { + testShow() << __func__; + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = "server1.p12"; + + auto serv(serv_conf.build().addSource("whoami", std::make_shared())); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = "ioc1.p12"; + + auto cli(cli_conf.build()); + + serv.start(); + + epicsEvent evt; + auto sub(cli.monitor("whoami").maskConnected(false).maskDisconnected(false).event([&evt](client::Subscription&) { evt.signal(); }).exec()); + Value update; + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, "x509"); + testEq(e.cred->account, "server1"); + } + testDiag("Connect"); + + update = pop(sub, evt); + testEq(update["value"].as(), "x509/ioc1"); + + serv_conf = serv.config(); + serv_conf.tls_cert_filename = "ioc1.p12"; + testDiag("serv.reconfigure()"); + serv.reconfigure(serv_conf); + + testThrows([&sub, &evt] { pop(sub, evt); }); + testDiag("Disconnect"); + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, "x509"); + testEq(e.cred->account, "ioc1"); + } + testDiag("Reconnect"); + + update = pop(sub, evt); + testEq(update["value"].as(), "x509/ioc1"); +} + +} // namespace + +MAIN(testtls) { + testPlan(26); + testSetup(); + logger_config_env(); + testClientBackwardsCompatibility(); + testServerBackwardsCompatibility(); + testGetSuper(); + testGetIntermediate(); + testGetNameServer(); + testClientReconfig(); + testServerReconfig(); + cleanup_for_valgrind(); + return testDone(); +} diff --git a/test/testtlsstatus.cpp b/test/testtlsstatus.cpp new file mode 100644 index 000000000..f0e83f6a7 --- /dev/null +++ b/test/testtlsstatus.cpp @@ -0,0 +1,329 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +#define PVXS_ENABLE_EXPERT_API + +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include "certfactory.h" +#include "certstatus.h" +#include "certstatusfactory.h" +#include "certstatusmanager.h" +#include "openssl.h" +#include "ownedptr.h" +#include "testcerts.h" + +namespace { +using namespace pvxs; +using namespace pvxs::certs; + +struct Tester { + // Pristine values + const StatusDate now; + const StatusDate status_valid_until_time; + const StatusDate revocation_date; + const Value status_value_prototype{CertStatus::getStatusPrototype()}; + + DEFINE_MEMBERS(ca) + DEFINE_MEMBERS(server1) + DEFINE_MEMBERS(client1) + server::SharedWildcardPV status_pv{server::SharedWildcardPV::buildMailbox()}; + server::Server pvacms{server::Config::isolated().build().addPV(GET_MONITOR_CERT_STATUS_PV, status_pv)}; + client::Context client{pvacms.clientConfig().build()}; + + Tester() + : now(time(nullptr)), + status_valid_until_time(now.t + STATUS_VALID_FOR_SECS), + revocation_date(now.t - REVOKED_SINCE_SECS) INIT_CERT_MEMBER_FROM_FILE(ca, CA) INIT_CERT_MEMBER_FROM_FILE(server1, SERVER1) + INIT_CERT_MEMBER_FROM_FILE(client1, CLIENT1) { + if (CHECK_CERT_MEMBER_CONDITION(ca) || CHECK_CERT_MEMBER_CONDITION(server1) || CHECK_CERT_MEMBER_CONDITION(client1)) { + testFail("Error loading one or more certificates"); + return; + } + testShow() << "Testing TLS Status Functions:\n"; + } + + ~Tester() = default; + + void initialisation() { + testShow() << __func__; + testEq(now.t, status_valid_until_time.t - STATUS_VALID_FOR_SECS); + testEq(now.t, revocation_date.t + REVOKED_SINCE_SECS); + } + + void ocspPayload() { + testShow() << __func__; + try { + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); + CREATE_CERT_STATUS(ca, {VALID}); + CREATE_CERT_STATUS(server1, {PENDING}); + CREATE_CERT_STATUS(client1, {REVOKED}); + } catch (std::exception &e) { + testFail("Failed to read certificate in from file: %s\n", e.what()); + } + } + + void parse() { + testShow() << __func__; + try { + testDiag("Parsing OCSP Response: %s", "Client certificate"); + auto parsed_response = CertStatusManager::parse(client1_cert_status.ocsp_bytes); + testDiag("Parsed OCSP Response: %s", "Client certificate"); + + testEq(parsed_response.serial, client1_serial); + testEq(parsed_response.ocsp_status.i, OCSP_CERTSTATUS_REVOKED); + testEq(parsed_response.status_date.t, now.t); + testEq(parsed_response.status_valid_until_date.t, status_valid_until_time.t); + testEq(parsed_response.revocation_date.t, revocation_date.t); + } catch (std::exception &e) { + testFail("Failed to parse Client OCSP response: %s", e.what()); + } + + try { + testDiag("Parsing OCSP Response: %s", "Server certificate"); + auto parsed_response = CertStatusManager::parse(server1_cert_status.ocsp_bytes); + testDiag("Parsed OCSP Response: %s", "Server certificate"); + + testEq(parsed_response.serial, server1_serial); + testEq(parsed_response.ocsp_status.i, OCSP_CERTSTATUS_UNKNOWN); + testEq(parsed_response.status_date.t, now.t); + testEq(parsed_response.status_valid_until_date.t, status_valid_until_time.t); + testEq(parsed_response.revocation_date.t, 0); + } catch (std::exception &e) { + testFail("Failed to parse Server OCSP response: %s", e.what()); + } + + try { + testDiag("Parsing OCSP Response: %s", "CA certificate"); + auto parsed_response = CertStatusManager::parse(ca_cert_status.ocsp_bytes); + testDiag("Parsed OCSP Response: %s", "CA certificate"); + + testEq(parsed_response.serial, ca_serial); + testEq(parsed_response.ocsp_status.i, OCSP_CERTSTATUS_GOOD); + testEq(parsed_response.status_date.t, now.t); + testEq(parsed_response.status_valid_until_date.t, status_valid_until_time.t); + testEq(parsed_response.revocation_date.t, 0); + } catch (std::exception &e) { + testFail("Failed to parse CA OCSP response: %s", e.what()); + } + } + + void makeStatusResponses() { + testShow() << __func__; + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); + MAKE_STATUS_RESPONSE(ca) + MAKE_STATUS_RESPONSE(server1) + MAKE_STATUS_RESPONSE(client1) + } + + void testStatusConversions() { + testShow() << __func__; + try { + auto unknown_status = UnknownCertificateStatus(); + { + testDiag("PVACertificateStatus ==> CertificateStatus"); + auto client_cs = (CertifiedCertificateStatus)client1_cert_status; + auto server_cs = (CertifiedCertificateStatus)server1_cert_status; + auto ca_cs = (CertifiedCertificateStatus)ca_cert_status; + + testDiag("CertificateStatus == PVACertificateStatus"); + testOk1(client_cs == client1_cert_status); // REVOKED == REVOKED + testOk1(server_cs == server1_cert_status); // PENDING == PENDING + testOk1(ca_cs == ca_cert_status); // VALID == VALID + + testDiag("PVACertificateStatus == CertificateStatus"); + testOk1(client1_cert_status == client_cs); // REVOKED == REVOKED + testOk1(server1_cert_status == server_cs); // PENDING == PENDING + testOk1(ca_cert_status == ca_cs); // VALID == VALID + + testDiag("CertificateStatus != PVACertificateStatus"); + testOk1(client_cs != ca_cert_status); // REVOKED != VALID + testOk1(server_cs != client1_cert_status); // PENDING != REVOKED + testOk1(ca_cs != server1_cert_status); // VALID != PENDING + + testDiag("PVACertificateStatus != CertificateStatus"); + testOk1(ca_cert_status != client_cs); // VALID != REVOKED + testOk1(client1_cert_status != server_cs); // REVOKED != UNKNOWN + testOk1(server1_cert_status != ca_cs); // PENDING != VALID + } + + { + testDiag("PVACertificateStatus ==> OCSPStatus"); + auto client_ocs = (OCSPStatus)client1_cert_status; + auto server_ocs = (OCSPStatus)server1_cert_status; + auto ca_ocs = (OCSPStatus)ca_cert_status; + + testDiag("OCSPStatus == PVACertificateStatus"); + testOk1(client_ocs == client1_cert_status); // REVOKED == REVOKED + testOk1(ca_ocs == ca_cert_status); // VALID == VALID + + testDiag("PVACertificateStatus == OCSPStatus"); + testOk1(client1_cert_status == client_ocs); // REVOKED == REVOKED + testOk1(ca_cert_status == ca_ocs); // VALID == VALID + + testDiag("OCSPStatus != PVACertificateStatus"); + testOk1(client_ocs != ca_cert_status); // REVOKED != VALID + testOk1(ca_ocs != server1_cert_status); // VALID != PENDING + + testOk1(server_ocs != server1_cert_status); // UNKNOWN == PENDING + testOk1(server_ocs != client1_cert_status); // UNKNOWN != REVOKED + + testDiag("PVACertificateStatus != OCSPStatus"); + testOk1(ca_cert_status != client_ocs); // VALID != REVOKED + testOk1(server1_cert_status != ca_ocs); // PENDING != VALID + + testOk1(server1_cert_status != server_ocs); // PENDING == UNKNOWN + testOk1(client1_cert_status != server_ocs); // REVOKED != UNKNOWN + } + + { + testDiag("PVACertificateStatus ==> certstatus_t"); + auto client_cs_t = (certstatus_t)client1_cert_status.status.i; + auto server_cs_t = (certstatus_t)server1_cert_status.status.i; + auto ca_cs_t = (certstatus_t)ca_cert_status.status.i; + + testDiag("certstatus_t == PVACertificateStatus"); + testOk1(client_cs_t == client1_cert_status); // REVOKED == REVOKED + testOk1(server_cs_t == server1_cert_status); // PENDING == PENDING + testOk1(ca_cs_t == ca_cert_status); // VALID == VALID + + testDiag("PVACertificateStatus == certstatus_t"); + testOk1(client1_cert_status == client_cs_t); // REVOKED == REVOKED + testOk1(server1_cert_status == server_cs_t); // PENDING == PENDING + testOk1(ca_cert_status == ca_cs_t); // VALID == VALID + + testDiag("certstatus_t != PVACertificateStatus"); + testOk1(client_cs_t != ca_cert_status); // REVOKED != VALID + testOk1(server_cs_t != client1_cert_status); // PENDING != REVOKED + testOk1(ca_cs_t != server1_cert_status); // VALID != PENDING + + testDiag("PVACertificateStatus != certstatus_t"); + testOk1(ca_cert_status != client_cs_t); // VALID != REVOKED + testOk1(client1_cert_status != server_cs_t); // REVOKED != UNKNOWN + testOk1(server1_cert_status != ca_cs_t); // PENDING != VALID + } + + { + testDiag("PVACertificateStatus ==> certstatus_t & OCSPStatus"); + auto client_cs_t = (certstatus_t)client1_cert_status.status.i; + auto server_cs_t = (certstatus_t)server1_cert_status.status.i; + auto ca_cs_t = (certstatus_t)ca_cert_status.status.i; + + auto client_ocs = (OCSPStatus)client1_cert_status; + auto server_ocs = (OCSPStatus)server1_cert_status; + auto ca_ocs = (OCSPStatus)ca_cert_status; + + testDiag("OCSPStatus == certstatus_t"); + testOk1(client_ocs == client_cs_t); // REVOKED == REVOKED + testOk1(server_ocs != server_cs_t); // UNKNOWN != PENDING + testOk1(ca_ocs == ca_cs_t); // VALID == VALID + + testDiag("certstatus_t == OCSPStatus"); + testOk1(client_cs_t == client_ocs); // REVOKED == REVOKED + testOk1(server_cs_t != server_ocs); // PENDING != UNKNOWN + testOk1(ca_cs_t == ca_ocs); // VALID == VALID + + testDiag("certstatus_t != OCSPStatus"); + testOk1(client_ocs != ca_cs_t); // REVOKED != VALID + testOk1(server_ocs != client_cs_t); // UNKNOWN != REVOKED + testOk1(ca_ocs != server_cs_t); // VALID != PENDING + + testDiag("OCSPStatus != certstatus_t"); + testOk1(client_cs_t != ca_ocs); // VALID != REVOKED + testOk1(server_cs_t != client_ocs); // REVOKED != UNKNOWN + testOk1(ca_cs_t != server_ocs); // PENDING != VALID + } + + } catch (std::exception &e) { + testFail("Failed test status conversions: %s", e.what()); + } + } + + void makeStatusRequest() { + testShow() << __func__; + try { + testDiag("Setting up: %s", "Mock PVACMS Server"); + + SET_PV(ca) + SET_PV(server1) + SET_PV(client1) + + status_pv.onFirstConnect([this](server::SharedWildcardPV &pv, const std::string &pv_name, const std::list ¶meters) { + auto it = parameters.begin(); + const std::string &serial_string = *++it; + uint64_t serial = std::stoull(serial_string); + + if (pv.isOpen(pv_name)) { + switch (serial) { + POST_VALUE_CASE(ca, post) + POST_VALUE_CASE(server1, post) + POST_VALUE_CASE(client1, post) + default: + testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + } + } else { + switch (serial) { + POST_VALUE_CASE(ca, open) + POST_VALUE_CASE(server1, open) + POST_VALUE_CASE(client1, open) + default: + testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + } + } + + testDiag("Posted Value for request: %s", pv_name.c_str()); + }); + status_pv.onLastDisconnect([](server::SharedWildcardPV &pv, const std::string &pv_name, const std::list ¶meters) { + testOk(1, "Closing Status Request Connection: %s", pv_name.c_str()); + pv.close(pv_name); + }); + + pvacms.start(); + + testDiag("Set up: %s", "Mock PVACMS Server"); + + TEST_STATUS_REQUEST(client1) + TEST_STATUS_REQUEST(server1) + TEST_STATUS_REQUEST(client1) + + testDiag("Stop Mock PVACMS server"); + pvacms.stop(); + } catch (std::exception &e) { + testFail("Failed to set up Mock PVACMS Server: %s", e.what()); + } + } +}; + +} // namespace + +MAIN(testtlsstatus) { + // Initialize SSL + pvxs::ossl::SSLContext::sslInit(); + + testPlan(121); + testSetup(); + logger_config_env(); + auto tester = new Tester(); + tester->initialisation(); + tester->ocspPayload(); + tester->parse(); + tester->makeStatusResponses(); + tester->testStatusConversions(); + tester->makeStatusRequest(); + delete (tester); + cleanup_for_valgrind(); + return testDone(); +} diff --git a/test/testtlstime.cpp b/test/testtlstime.cpp new file mode 100644 index 000000000..20ed4d5fb --- /dev/null +++ b/test/testtlstime.cpp @@ -0,0 +1,88 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +#define PVXS_ENABLE_EXPERT_API + +#include + +#include +#include + +#include +#include + +#include "certstatus.h" + +namespace { +using namespace pvxs; + +#define ONE_DAY_OF_SECONDS (60 * 60 * 24) + +struct Tester { + // Pristine values + const time_t now; + const time_t future; + const std::string now_string; + const std::string future_string; + + // For testing Status date + const certs::StatusDate date_now; + const certs::StatusDate date_future; + + Tester() + : now(time(nullptr)), + future(now + ONE_DAY_OF_SECONDS), + now_string(((certs::StatusDate)now).s), + future_string(certs::StatusDate(future).s), + date_now(now), + date_future(future) { + testShow() << "Testing TLS Date Functions:\n"; + } + + ~Tester() = default; + + void initialisation() { + testShow() << __func__; + testEq(now, date_now.t); + testEq(future, date_future.t); + testEq(now_string, date_now.s); + testEq(future_string, date_future.s); + } + + void conversion() { + testShow() << __func__; + testEq(now, ((certs::StatusDate)date_now.s).t); + testEq(future, ((certs::StatusDate)date_future.s).t); + testEq(now_string, certs::StatusDate(date_now.t).s); + testEq(future_string, certs::StatusDate(date_future.t).s); + } + + void asn1_time() { + testShow() << __func__; + ossl_ptr now_asn1(ASN1_TIME_new()); + ASN1_TIME_set(now_asn1.get(), now); + ossl_ptr future_asn1(ASN1_TIME_new()); + ASN1_TIME_set(future_asn1.get(), future); + + testEq(now, ((certs::StatusDate)now_asn1).t); + testEq(future, ((certs::StatusDate)future_asn1.get()).t); + + testEq(now, ((certs::StatusDate)date_now.toAsn1_Time().get()).t); + testEq(future, ((certs::StatusDate)certs::StatusDate::toAsn1_Time(date_future).get()).t); + } +}; + +} // namespace + +MAIN(testtlstime) { + testPlan(12); + testSetup(); + logger_config_env(); + Tester().initialisation(); + Tester().conversion(); + Tester().asn1_time(); + cleanup_for_valgrind(); + return testDone(); +} diff --git a/test/testtlswithcms.cpp b/test/testtlswithcms.cpp new file mode 100644 index 000000000..efa8f179a --- /dev/null +++ b/test/testtlswithcms.cpp @@ -0,0 +1,705 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +#define PVXS_ENABLE_EXPERT_API + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "certfactory.h" +#include "certstatus.h" +#include "certstatusfactory.h" +#include "certstatusmanager.h" +#include "testcerts.h" +#include "utilpvt.h" + +/** + * @brief This tester uses a Tester object and a bunch of MACROS that rely on a very opinionated + * set of named variables to function. prefixes `ca`, `super_server`, `intermediate_server`, + * `server1`, `server2`, `ioc`, `client1`, and `client2` refer to the certificates generated + * by `gen_test_certs`. `ca` is used for the Certificate Authority and `super_server` is used + * for the Mock PVACMS. + * + * `gen_test_certs` has been modified to generate the ca cert and the Mock PVACMS cert without + * status extensions for obvious reasons. + * + * The tests initially follow the exact same sequence as those in the `testtls` suite and then try out some + * edge conditions such as the Mock PVACMS being unavailable and the Mock PVACMS returning non GOOD statuses. + * + */ +using namespace pvxs; +using namespace pvxs::certs; + +namespace { + +/** + * @class Tester + * @brief A class used for testing tls while monitoring certificate statuses against a Mock PVACMS server. + */ +struct Tester { + const StatusDate now; + const StatusDate status_valid_until_time; + const StatusDate revocation_date; + + const Value status_value_prototype{CertStatus::getStatusPrototype()}; + DEFINE_MEMBERS(ca) + DEFINE_MEMBERS(super_server) + DEFINE_MEMBERS(intermediate_server) + DEFINE_MEMBERS(server1) + DEFINE_MEMBERS(server2) + DEFINE_MEMBERS(ioc) + DEFINE_MEMBERS(client1) + DEFINE_MEMBERS(client2) + + server::SharedWildcardPV status_pv{server::SharedWildcardPV::buildMailbox()}; + server::Server pvacms; + client::Context client; + + Tester() + : now(time(nullptr)), + status_valid_until_time(now.t + STATUS_VALID_FOR_SECS), + revocation_date(now.t - REVOKED_SINCE_SECS) + + INIT_CERT_MEMBER_FROM_FILE(ca, CA) INIT_CERT_MEMBER_FROM_FILE(super_server, SUPER_SERVER) + INIT_CERT_MEMBER_FROM_FILE(intermediate_server, INTERMEDIATE_SERVER) INIT_CERT_MEMBER_FROM_FILE(server1, SERVER1) + INIT_CERT_MEMBER_FROM_FILE(server2, SERVER2) INIT_CERT_MEMBER_FROM_FILE(ioc, IOC1) INIT_CERT_MEMBER_FROM_FILE(client1, CLIENT1) + INIT_CERT_MEMBER_FROM_FILE(client2, CLIENT2) + + { + // Set up the Mock PVACMS server certificate (does not contain custom status extension) + auto pvacms_config = server::Config::fromEnv(); + pvacms_config.tls_cert_filename = SUPER_SERVER_CERT_FILE; + pvacms_config.tls_disable_status_check = true; + pvacms_config.tls_disable_stapling = true; + pvacms_config.config_target = pvxs::impl::ConfigCommon::CMS; + pvacms = pvacms_config.build().addPV(GET_MONITOR_CERT_STATUS_PV, status_pv); + client = pvacms.clientConfig().build(); + + if (CHECK_CERT_MEMBER_CONDITION(ca) || CHECK_CERT_MEMBER_CONDITION(super_server) || CHECK_CERT_MEMBER_CONDITION(intermediate_server) || + CHECK_CERT_MEMBER_CONDITION(server1) || CHECK_CERT_MEMBER_CONDITION(server2) || CHECK_CERT_MEMBER_CONDITION(ioc) || + CHECK_CERT_MEMBER_CONDITION(client1) || CHECK_CERT_MEMBER_CONDITION(client2)) { + testFail("Error loading one or more certificates"); + return; + } + testShow() << "Loaded all test certs\n"; + } + + ~Tester() {}; + + /** + * @brief Creates certificate statuses. + * + * This function generates mock statuses to be returned by the Mock CMS server. + * These statuses are replete with valid OCSP responses that are valid for `STATUS_VALID_FOR_MINS` minutes + */ + void createCertStatuses() { + testShow() << __func__; + try { + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); + CREATE_CERT_STATUS(ca, {VALID}) + CREATE_CERT_STATUS(intermediate_server, {VALID}) + CREATE_CERT_STATUS(server1, {VALID}) + CREATE_CERT_STATUS(server2, {VALID}) + CREATE_CERT_STATUS(ioc, {VALID}) + CREATE_CERT_STATUS(client1, {VALID}) + CREATE_CERT_STATUS(client2, {VALID}) + } catch (std::exception& e) { + testFail("Failed to read certificate in from file: %s\n", e.what()); + } + } + + /** + * @brief Make PVAccess Certificate Status Responses for each of the certificates + */ + void makeStatusResponses() { + testShow() << __func__; + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); + MAKE_STATUS_RESPONSE(ca) + MAKE_STATUS_RESPONSE(intermediate_server) + MAKE_STATUS_RESPONSE(server1) + MAKE_STATUS_RESPONSE(server2) + MAKE_STATUS_RESPONSE(ioc) + MAKE_STATUS_RESPONSE(client1) + MAKE_STATUS_RESPONSE(client2) + } + + /** + * @brief Pop the next event off the subscribed PV's queue + * @param sub the subscription + * @param evt the epics event + * @return the popped Value or empty on timeout + */ + Value pop(const std::shared_ptr& sub, epicsEvent& evt) { + while (true) { + if (auto ret = sub->pop()) { + return ret; + + } else if (!evt.wait(5.0)) { + testFail("timeout waiting for event"); + return Value(); + } + } + } + + /** + * @brief Start the Mock PVACMS service] + * + * Important; This server is implemented by using the standard SharedWildcardPV so it + * also tests this newly exposed feature. + * + * This essentially creates a server that will serve a `SharedWildcardPV` that responds to + * PVs corresponding to the certificate status request pattern. It will respond to only those PVs that were + * generated by `gen_test_certs` anx only with the responses created in `createCertStatuses` + * unless changed by putting values like 'APPROVED', 'DENIED', or 'REVOKED' to the 'state' field. + * + * During the setup tests are performed to verify that it works as expected + * + */ + void startMockCMS() { + testShow() << __func__; + try { + testDiag("Setting up: %s", "Mock PVACMS Server"); + + SET_PV(ca) + SET_PV(intermediate_server) + SET_PV(server1) + SET_PV(server2) + SET_PV(ioc) + SET_PV(client1) + SET_PV(client2) + + status_pv.onFirstConnect([this](server::SharedWildcardPV& pv, const std::string& pv_name, const std::list& parameters) { + auto it = parameters.begin(); + const std::string& serial_string = *++it; + uint64_t serial = std::stoull(serial_string); + + if (pv.isOpen(pv_name)) { + switch (serial) { + POST_VALUE_CASE(ca, post) + POST_VALUE_CASE(intermediate_server, post) + POST_VALUE_CASE(server1, post) + POST_VALUE_CASE(server2, post) + POST_VALUE_CASE(ioc, post) + POST_VALUE_CASE(client1, post) + POST_VALUE_CASE(client2, post) + default: + testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + } + } else { + switch (serial) { + POST_VALUE_CASE(ca, open) + POST_VALUE_CASE(intermediate_server, open) + POST_VALUE_CASE(server1, open) + POST_VALUE_CASE(server2, open) + POST_VALUE_CASE(ioc, open) + POST_VALUE_CASE(client1, open) + POST_VALUE_CASE(client2, open) + default: + testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + } + } + }); + status_pv.onLastDisconnect([](server::SharedWildcardPV& pv, const std::string& pv_name, const std::list& parameters) { + testOk(1, "Closing Status Request Connection: %s", pv_name.c_str()); + pv.close(pv_name); + }); + + pvacms.start(); + + testDiag("Set up: %s", "Mock PVACMS Server"); + } catch (std::exception& e) { + testFail("Failed to set up Mock PVACMS Server: %s", e.what()); + } + } + + /** + * @brief Stop the Mock PVACMS Server + */ + void stopMockCMS() { + testShow() << __func__; + try { + testDiag("Stopping: %s", "Mock PVACMS Server"); + pvacms.stop(); + } catch (std::exception& e) { + testFail("Failed to stop Mock PVACMS Server: %s", e.what()); + } + } + + /** + * @brief A Secure PVAccess Server that responds to the "whoami" PV. + * + * It will return the credentials that are added to the PVAccess operations + * by the implemented TLS framework that are taken from the provided certificates. + * This can be used to test whether the correct credentials and attributions are being + * assigned. + * + * It pulls the `method` and `account` from the PVAccess operation (GET/PUT/MONITOR/RPC). + * The values corresponds to the following: + * `method`: 'ca' for `tcp` connections, and `x509` for `tls` connections + * `account`: the subject `CN` (common name) encoded in the certificate for `tls` connections, + * or "ca" or "anonymous" for `tcp` connections + */ + struct WhoAmI final : public server::Source { + const Value resultType; + + WhoAmI() : resultType(nt::NTScalar(TypeCode::String).create()) {} + + virtual void onSearch(Search& op) override final { + for (auto& pv : op) { + if (strcmp(pv.name(), WHO_AM_I_PV) == 0) pv.claim(); + } + } + + virtual void onCreate(std::unique_ptr&& op) override final { + if (op->name() != WHO_AM_I_PV) return; + + // Handle GET + op->onOp([this](std::unique_ptr&& cop) { + cop->onGet([this](std::unique_ptr&& eop) { eop->reply(getWhoAmIValue(eop->credentials())); }); + + cop->connect(resultType); + }); + + // Handle MONITOR + std::shared_ptr sub; + op->onSubscribe([this, sub](std::unique_ptr&& sop) mutable { + sub = sop->connect(resultType); + sub->post(getWhoAmIValue(sub->credentials())); + }); + } + + // Create the concatenated whoami response string from the `method` and `account` + inline Value getWhoAmIValue(std::shared_ptr cred) { + std::ostringstream strm; + strm << cred->method << '/' << cred->account; + return resultType.cloneEmpty().update(TEST_PV_FIELD, strm.str()); + } + }; + + /** + * @brief Test getting a value using a certificate that is configured to use an intermediate CA + * Note that we don't disable status monitoring so therefore the framework will attempt to contact + * PVACMS to verify certificate status for any certificates that contain the certificate status extension. + * + * We chose the SERVER1 and CLIENT1 certificates for this test which as well as both being + * certificates that have an intermediate certificate between them and the root CA, they + * also have the certificate status extension embedded in them. So this test will + * verify that the statuses are verified and the TLS proceeds as expected. If the + * statuses are not verified then the test count will be off because there is a test + * in the Mock PVACMS when certificate statuses are posted. + * + * The test to make sure that the connection is a tls connection here serves to verify that + * the successful status verification does indeed result in a secure PVAccess connection being + * established. + */ + void testGetIntermediate() { + testShow() << __func__; + RESET_COUNTER(server1) + RESET_COUNTER(client1) + + auto test_pv_value(nt::NTScalar{TypeCode::Int32}.create()); + auto test_pv(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + auto serv(serv_conf.build().addPV(TEST_PV, test_pv)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + auto cli(cli_conf.build()); + + test_pv.open(test_pv_value.update(TEST_PV_FIELD, 42)); + serv.start(); + + auto conn(cli.connect(TEST_PV).onConnect([](const client::Connected& c) { testTrue(c.cred && c.cred->isTLS); }).exec()); + + auto reply(cli.get(TEST_PV).exec()->wait(5.0)); + testEq(reply[TEST_PV_FIELD].as(), 42); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + + conn.reset(); + } + + /** + * @brief This test verifies that the client connection is successfully reestablished after a client reconfigure + * is triggered. + * + * A client can now reconfigure its connection to use different tls configuration. We will first create a connection + * using one tls configuration then we will reconfigure the connection using a different configuration + * and check whether the changes are successfully applied to the connection. + * + * The simple way we do this is to create a server that will simply return the common name of the identity + * presented in the client certificate (via the Subject common name CN) and the method by which the connection + * is made (x509 for tls connections). If we change configuration then this value will change to the new + * credentials presented by the newly configured client. + * + * CLIENT1 and CLIENT2 are used as the different client configuration and IOC1 is used for the server's config. + * + * As this uses the Mock PVACMS we verify that it checks certificate status before using the certificates + */ + void testClientReconfig() { + testShow() << __func__; + RESET_COUNTER(ioc) + RESET_COUNTER(client1) + RESET_COUNTER(client2) + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = IOC1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + + auto serv(serv_conf.build().addSource(WHO_AM_I_PV, std::make_shared())); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + + auto cli(cli_conf.build()); + + serv.start(); + + epicsEvent evt; + auto sub(cli.monitor(WHO_AM_I_PV).maskConnected(false).maskDisconnected(false).event([&evt](client::Subscription&) { evt.signal(); }).exec()); + Value update; + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, TLS_METHOD_STRING); + testEq(e.cred->account, CERT_CN_IOC1); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 0) + } + testDiag("Connect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT1); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 0) + + cli_conf = cli.config(); + cli_conf.tls_cert_filename = CLIENT2_CERT_FILE; + cli_conf.tls_cert_password = CLIENT2_CERT_FILE_PWD; + cli_conf.tls_disable_stapling = true; + testDiag("cli.reconfigure()"); + cli.reconfigure(cli_conf); + + testThrows([this, &sub, &evt] { pop(sub, evt); }); + testDiag("Disconnect"); + + try { + (void)pop(sub, evt); + testFail("Missing expected Connected"); + } catch (client::Connected& e) { + testOk1(e.cred && e.cred->isTLS); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 1) + } catch (...) { + testFail("Unexpected exception instead of Connected"); + } + testDiag("Reconnect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT2); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 1) + } + + /** + * @brief Tests that new configuration is applied to all server connections when the server reconfigure is executed. + * + * Here we use the SERVER1 and IOC1 certificates for the server and check that after a reconfigure the + * tls session is re-established but using the new configuration. + * + * As this uses the Mock PVACMS we verify that it checks certificate status before using the certificates + */ + void testServerReconfig() { + testShow() << __func__; + RESET_COUNTER(server1) + RESET_COUNTER(client1) + RESET_COUNTER(ioc) + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + + auto serv(serv_conf.build().addSource(WHO_AM_I_PV, std::make_shared())); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + + auto cli(cli_conf.build()); + + serv.start(); + + epicsEvent evt; + auto sub(cli.monitor(WHO_AM_I_PV).maskConnected(false).maskDisconnected(false).event([&evt](client::Subscription&) { evt.signal(); }).exec()); + Value update; + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, TLS_METHOD_STRING); + testEq(e.cred->account, CERT_CN_SERVER1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 0) + } + testDiag("Connect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 0) + + serv_conf = serv.config(); + serv_conf.tls_cert_filename = IOC1_CERT_FILE; + testDiag("serv.reconfigure()"); + serv.reconfigure(serv_conf); + + testThrows([this, &sub, &evt] { pop(sub, evt); }); + testDiag("Disconnect"); + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, TLS_METHOD_STRING); + testEq(e.cred->account, CERT_CN_IOC1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 1) + } + testDiag("Reconnect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 1) + } + + /** + * @brief Test getting a value using a certificate that is configured to use an intermediate CA + * Note that we don't disable status monitoring so therefore the framework will attempt to contact + * PVACMS to verify certificate status for any certificates that contain the certificate status extension. + * + * We chose the SERVER1 and CLIENT1 certificates for this test which as well as both being + * certificates that have an intermediate certificate between them and the root CA, they + * also have the certificate status extension embedded in them. So this test will + * verify that the statuses are verified and the TLS proceeds as expected. If the + * statuses are not verified then the test count will be off because there is a test + * in the Mock PVACMS when certificate statuses are posted. + * + * The test to make sure that the connection is a tls connection (isTLS) here serves to verify that + * the successful status verification does indeed result in a secure PVAccess connection being + * established. + */ + void testUnCachedStatus() { + testShow() << __func__; + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SHORT_SECS)); + MAKE_STATUS_RESPONSE(ca) + MAKE_STATUS_RESPONSE(intermediate_server) + MAKE_STATUS_RESPONSE(server1) + MAKE_STATUS_RESPONSE(server2) + MAKE_STATUS_RESPONSE(ioc) + MAKE_STATUS_RESPONSE(client1) + MAKE_STATUS_RESPONSE(client2) + + RESET_COUNTER(server1) + RESET_COUNTER(client1) + + auto test_pv_value(nt::NTScalar{TypeCode::Int32}.create()); + auto test_pv(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + auto serv(serv_conf.build().addPV(TEST_PV, test_pv)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + + auto cli(cli_conf.build()); + + test_pv.open(test_pv_value.update(TEST_PV_FIELD, 42)); + serv.start(); + sleep(1); + + auto conn(cli.connect(TEST_PV) + .onConnect([](const client::Connected& c) { + testTrue(c.cred && c.cred->isTLS); + sleep(1); + }) + .exec()); + sleep(1); + + auto reply(cli.get(TEST_PV).exec()->wait(5.0)); + testEq(reply[TEST_PV_FIELD].as(), 42); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + + conn.reset(); + } + + /** + * @brief This test checks that tls connections are prohibited when CMS is unavailable but configuration requires it + * + * The Mock PVACMS must be previously stopped prior to this test + * + */ + void testCMSUnavailable() { + testShow() << __func__; + // Create a test PV and set value to 42 + auto test_pv_value(nt::NTScalar{TypeCode::Int32}.create()); + auto test_pv(server::SharedPV::buildReadonly()); + test_pv.open(test_pv_value.update(TEST_PV_FIELD, 42)); + { + // Configure server with status checking enabled + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = IOC1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + serv_conf.tls_throw_if_no_cert = true; + + // Test that server will not start because the Mock CMS is not running + try { + auto serv(serv_conf.build().addPV(TEST_PV, test_pv)); + testFail("Unexpected successful creation of server"); + } catch ( std::runtime_error &e) { + testStrEq("Unable to contact PVACMS: Waiting for PVACMS to report status for cert " IOC1_CERT_FILE, e.what()); + } + + // Now lets try again with status checking disabled so we can test the client + serv_conf.tls_disable_status_check = true; + auto serv(serv_conf.build().addPV(TEST_PV1, test_pv)); + // Start the server + serv.start(); + + // Configure client with status checking enabled + epicsEvent client_started_evt; + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + cli_conf.tls_disable_status_check = false; + auto cli(cli_conf.build()); + + try { + auto val(cli.get(TEST_PV1).exec()->wait(1.0)); + testFail("Unexpected Success"); + } catch (std::exception &e) { + testStrEq("Timeout", e.what()); + } + + // Try again with a monitor + auto sub(cli.monitor(TEST_PV1) + .maskConnected(false) + .maskDisconnected(false) + .event([&client_started_evt](client::Subscription&) { client_started_evt.signal(); }) + .exec()); + + // Wait for the client to fail to connect + testTrue(!client_started_evt.wait(1.0)); + } + + { + // Configure server with status checking disabled + auto serv_conf2(server::Config::isolated()); + serv_conf2.tls_cert_filename = IOC1_CERT_FILE; + serv_conf2.tls_disable_status_check = true; + auto serv2(serv_conf2.build().addPV(TEST_PV2, test_pv)); + + // Configure client with status checking disabled + auto cli_conf2(serv2.clientConfig()); + cli_conf2.tls_cert_filename = CLIENT1_CERT_FILE; + auto cli2(cli_conf2.build()); + + // Start the server + serv2.start(); + + // Try to get the value of the PV + auto reply(cli2.get(TEST_PV2).exec()->wait()); + testEq(reply[TEST_PV_FIELD].as(), 42); + } + } +}; + +} // namespace + +/** + * @brief The main test runner + * @return test runner status (non-zero for errors) + */ +MAIN(testtlswithcms) { + // Initialize SSL + pvxs::ossl::SSLContext::sslInit(); + + testPlan(287); + testSetup(); + logger_config_env(); + auto tester = new Tester(); + tester->createCertStatuses(); + tester->makeStatusResponses(); + tester->startMockCMS(); + try { + tester->testGetIntermediate(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testClientReconfig(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testServerReconfig(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testUnCachedStatus(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->stopMockCMS(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testCMSUnavailable(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + delete(tester); + + cleanup_for_valgrind(); + + return testDone(); +} diff --git a/test/testtlswithcmsandstapling.cpp b/test/testtlswithcmsandstapling.cpp new file mode 100644 index 000000000..4f635fb27 --- /dev/null +++ b/test/testtlswithcmsandstapling.cpp @@ -0,0 +1,719 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ +#define PVXS_ENABLE_EXPERT_API + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "certfactory.h" +#include "certstatus.h" +#include "certstatusfactory.h" +#include "certstatusmanager.h" +#include "testcerts.h" +#include "utilpvt.h" + +/** + * @brief This tester uses a Tester object and a bunch of MACROS that rely on a very opinionated + * set of named variables to function. prefixes `ca`, `super_server`, `intermediate_server`, + * `server1`, `server2`, `ioc`, `client1`, and `client2` refer to the certificates generated + * by `gen_test_certs`. `ca` is used for the Certificate Authority and `super_server` is used + * for the Mock PVACMS. + * + * `gen_test_certs` has been modified to generate the ca cert and the Mock PVACMS cert without + * status extensions for obvious reasons. + * + * The tests initially follow the exact same sequence as those in the `testtls` suite and then try out some + * edge conditions such as requesting stapling but none being provided. + * + */ +using namespace pvxs; +using namespace pvxs::certs; + +namespace { + +/** + * @class Tester + * @brief A class used for testing tls while stapling certificate status from a Mock PVACMS server. + */ +struct Tester { + const StatusDate now; + const StatusDate status_valid_until_time; + const StatusDate revocation_date; + + const Value status_value_prototype{CertStatus::getStatusPrototype()}; + DEFINE_MEMBERS(ca) + DEFINE_MEMBERS(super_server) + DEFINE_MEMBERS(intermediate_server) + DEFINE_MEMBERS(server1) + DEFINE_MEMBERS(server2) + DEFINE_MEMBERS(ioc) + DEFINE_MEMBERS(client1) + DEFINE_MEMBERS(client2) + + server::SharedWildcardPV status_pv{server::SharedWildcardPV::buildMailbox()}; + server::Server pvacms; + client::Context client; + + Tester() + : now(time(nullptr)), + status_valid_until_time(now.t + STATUS_VALID_FOR_SECS), + revocation_date(now.t - REVOKED_SINCE_SECS) + + INIT_CERT_MEMBER_FROM_FILE(ca, CA) INIT_CERT_MEMBER_FROM_FILE(super_server, SUPER_SERVER) + INIT_CERT_MEMBER_FROM_FILE(intermediate_server, INTERMEDIATE_SERVER) INIT_CERT_MEMBER_FROM_FILE(server1, SERVER1) + INIT_CERT_MEMBER_FROM_FILE(server2, SERVER2) INIT_CERT_MEMBER_FROM_FILE(ioc, IOC1) INIT_CERT_MEMBER_FROM_FILE(client1, CLIENT1) + INIT_CERT_MEMBER_FROM_FILE(client2, CLIENT2) + + { + // Set up the Mock PVACMS server certificate (does not contain custom status extension) + auto pvacms_config = server::Config::fromEnv(); + pvacms_config.tls_cert_filename = SUPER_SERVER_CERT_FILE; + pvacms_config.tls_disable_status_check = true; + pvacms_config.config_target = pvxs::impl::ConfigCommon::CMS; + pvacms = pvacms_config.build().addPV(GET_MONITOR_CERT_STATUS_PV, status_pv); + client = pvacms.clientConfig().build(); + + if (CHECK_CERT_MEMBER_CONDITION(ca) || CHECK_CERT_MEMBER_CONDITION(super_server) || CHECK_CERT_MEMBER_CONDITION(intermediate_server) || + CHECK_CERT_MEMBER_CONDITION(server1) || CHECK_CERT_MEMBER_CONDITION(server2) || CHECK_CERT_MEMBER_CONDITION(ioc) || + CHECK_CERT_MEMBER_CONDITION(client1) || CHECK_CERT_MEMBER_CONDITION(client2)) { + testFail("Error loading one or more certificates"); + return; + } + testShow() << "Loaded all test certs\n"; + } + + ~Tester() {}; + + /** + * @brief Creates certificate statuses. + * + * This function generates mock statuses to be returned by the Mock CMS server. + * These statuses are replete with valid OCSP responses that are valid for `STATUS_VALID_FOR_MINS` minutes + */ + void createCertStatuses() { + testShow() << __func__; + try { + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); + CREATE_CERT_STATUS(ca, {VALID}) + CREATE_CERT_STATUS(intermediate_server, {VALID}) + CREATE_CERT_STATUS(server1, {VALID}) + CREATE_CERT_STATUS(server2, {VALID}) + CREATE_CERT_STATUS(ioc, {VALID}) + CREATE_CERT_STATUS(client1, {VALID}) + CREATE_CERT_STATUS(client2, {VALID}) + } catch (std::exception& e) { + testFail("Failed to read certificate in from file: %s\n", e.what()); + } + } + + /** + * @brief Make PVAccess Certificate Status Responses for each of the certificates + */ + void makeStatusResponses() { + testShow() << __func__; + auto cert_status_creator(CertStatusFactory(ca_cert.cert, ca_cert.pkey, ca_cert.chain, 0, STATUS_VALID_FOR_SECS)); + MAKE_STATUS_RESPONSE(ca) + MAKE_STATUS_RESPONSE(intermediate_server) + MAKE_STATUS_RESPONSE(server1) + MAKE_STATUS_RESPONSE(server2) + MAKE_STATUS_RESPONSE(ioc) + MAKE_STATUS_RESPONSE(client1) + MAKE_STATUS_RESPONSE(client2) + } + + /** + * @brief Pop the next event off the subscribed PV's queue + * @param sub the subscription + * @param evt the epics event + * @return the popped Value or empty on timeout + */ + Value pop(const std::shared_ptr& sub, epicsEvent& evt) { + while (true) { + if (auto ret = sub->pop()) { + return ret; + + } else if (!evt.wait(5.0)) { + testFail("timeout waiting for event"); + return Value(); + } + } + } + + /** + * @brief Start the Mock PVACMS service] + * + * Important; This server is implemented by using the standard SharedWildcardPV so it + * also tests this newly exposed feature. + * + * This essentially creates a server that will serve a `SharedWildcardPV` that responds to + * PVs corresponding to the certificate status request pattern. It will respond to only those PVs that were + * generated by `gen_test_certs` anx only with the responses created in `createCertStatuses` + * unless changed by putting values like 'APPROVED', 'DENIED', or 'REVOKED' to the 'state' field. + * + * During the setup tests are performed to verify that it works as expected + * + */ + void startMockCMS() { + testShow() << __func__; + try { + testDiag("Setting up: %s", "Mock PVACMS Server"); + + SET_PV(ca) + SET_PV(intermediate_server) + SET_PV(server1) + SET_PV(server2) + SET_PV(ioc) + SET_PV(client1) + SET_PV(client2) + + status_pv.onFirstConnect([this](server::SharedWildcardPV& pv, const std::string& pv_name, const std::list& parameters) { + auto it = parameters.begin(); + const std::string& serial_string = *++it; + uint64_t serial = std::stoull(serial_string); + + if (pv.isOpen(pv_name)) { + switch (serial) { + POST_VALUE_CASE(ca, post) + POST_VALUE_CASE(intermediate_server, post) + POST_VALUE_CASE(server1, post) + POST_VALUE_CASE(server2, post) + POST_VALUE_CASE(ioc, post) + POST_VALUE_CASE(client1, post) + POST_VALUE_CASE(client2, post) + default: + testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + } + } else { + switch (serial) { + POST_VALUE_CASE(ca, open) + POST_VALUE_CASE(intermediate_server, open) + POST_VALUE_CASE(server1, open) + POST_VALUE_CASE(server2, open) + POST_VALUE_CASE(ioc, open) + POST_VALUE_CASE(client1, open) + POST_VALUE_CASE(client2, open) + default: + testFail("Unknown PV Accessed for Status Request: %s", pv_name.c_str()); + } + } + }); + status_pv.onLastDisconnect([](server::SharedWildcardPV& pv, const std::string& pv_name, const std::list& parameters) { + testOk(1, "Closing Status Request Connection: %s", pv_name.c_str()); + pv.close(pv_name); + }); + + pvacms.start(); + + testDiag("Set up: %s", "Mock PVACMS Server"); + } catch (std::exception& e) { + testFail("Failed to set up Mock PVACMS Server: %s", e.what()); + } + } + + /** + * @brief Stop the Mock PVACMS Server + */ + void stopMockCMS() { + testShow() << __func__; + try { + testDiag("Stopping: %s", "Mock PVACMS Server"); + pvacms.stop(); + } catch (std::exception& e) { + testFail("Failed to stop Mock PVACMS Server: %s", e.what()); + } + } + + /** + * @brief A Secure PVAccess Server that responds to the "whoami" PV. + * + * It will return the credentials that are added to the PVAccess operations + * by the implemented TLS framework that are taken from the provided certificates. + * This can be used to test whether the correct credentials and attributions are being + * assigned. + * + * It pulls the `method` and `account` from the PVAccess operation (GET/PUT/MONITOR/RPC). + * The values corresponds to the following: + * `method`: 'ca' for `tcp` connections, and `x509` for `tls` connections + * `account`: the subject `CN` (common name) encoded in the certificate for `tls` connections, + * or "ca" or "anonymous" for `tcp` connections + */ + struct WhoAmI final : public server::Source { + const Value resultType; + + WhoAmI() : resultType(nt::NTScalar(TypeCode::String).create()) {} + + virtual void onSearch(Search& op) override final { + for (auto& pv : op) { + if (strcmp(pv.name(), WHO_AM_I_PV) == 0) pv.claim(); + } + } + + virtual void onCreate(std::unique_ptr&& op) override final { + if (op->name() != WHO_AM_I_PV) return; + + // Handle GET + op->onOp([this](std::unique_ptr&& cop) { + cop->onGet([this](std::unique_ptr&& eop) { eop->reply(getWhoAmIValue(eop->credentials())); }); + + cop->connect(resultType); + }); + + // Handle MONITOR + std::shared_ptr sub; + op->onSubscribe([this, sub](std::unique_ptr&& sop) mutable { + sub = sop->connect(resultType); + sub->post(getWhoAmIValue(sub->credentials())); + }); + } + + // Create the concatenated whoami response string from the `method` and `account` + inline Value getWhoAmIValue(std::shared_ptr cred) { + std::ostringstream strm; + strm << cred->method << '/' << cred->account; + return resultType.cloneEmpty().update(TEST_PV_FIELD, strm.str()); + } + }; + + /** + * @brief Test getting a value using a certificate that is configured to use an intermediate CA + * Note that we don't disable status monitoring so therefore the framework will attempt to contact + * PVACMS to verify certificate status for any certificates that contain the certificate status extension. + * + * We chose the SERVER1 and CLIENT1 certificates for this test which as well as both being + * certificates that have an intermediate certificate between them and the root CA, they + * also have the certificate status extension embedded in them. So this test will + * verify that the statuses are verified and the TLS proceeds as expected. If the + * statuses are not verified then the test count will be off because there is a test + * in the Mock PVACMS when certificate statuses are posted. + * + * The test to make sure that the connection is a tls connection here serves to verify that + * the successful status verification does indeed result in a secure PVAccess connection being + * established. + */ + void testGetIntermediate() { + testShow() << __func__; + RESET_COUNTER(server1) + RESET_COUNTER(client1) + + auto test_pv_value(nt::NTScalar{TypeCode::Int32}.create()); + auto test_pv(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + serv_conf.tls_disable_stapling = false; + auto serv(serv_conf.build().addPV(TEST_PV, test_pv)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + auto cli(cli_conf.build()); + + test_pv.open(test_pv_value.update(TEST_PV_FIELD, 42)); + serv.start(); + + auto conn(cli.connect(TEST_PV).onConnect([](const client::Connected& c) { testTrue(c.cred && c.cred->isTLS); }).exec()); + + auto reply(cli.get(TEST_PV).exec()->wait(5.0)); + testEq(reply[TEST_PV_FIELD].as(), 42); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + + conn.reset(); + } + + /** + * @brief This test verifies that the client connection is successfully reestablished after a client reconfigure + * is triggered. + * + * A client can now reconfigure its connection to use different tls configuration. We will first create a connection + * using one tls configuration then we will reconfigure the connection using a different configuration + * and check whether the changes are successfully applied to the connection. + * + * The simple way we do this is to create a server that will simply return the common name of the identity + * presented in the client certificate (via the Subject common name CN) and the method by which the connection + * is made (x509 for tls connections). If we change configuration then this value will change to the new + * credentials presented by the newly configured client. + * + * CLIENT1 and CLIENT2 are used as the different client configuration and IOC1 is used for the server's config. + * + * As this uses the Mock PVACMS we verify that it checks certificate status before using the certificates + */ + void testClientReconfig() { + testShow() << __func__; + RESET_COUNTER(ioc) + RESET_COUNTER(client1) + RESET_COUNTER(client2) + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = IOC1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + serv_conf.tls_disable_stapling = false; + + auto serv(serv_conf.build().addSource(WHO_AM_I_PV, std::make_shared())); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + + auto cli(cli_conf.build()); + + serv.start(); + + epicsEvent evt; + auto sub(cli.monitor(WHO_AM_I_PV).maskConnected(false).maskDisconnected(false).event([&evt](client::Subscription&) { evt.signal(); }).exec()); + Value update; + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, TLS_METHOD_STRING); + testEq(e.cred->account, CERT_CN_IOC1); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 0) + } + testDiag("Connect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT1); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 0) + + cli_conf = cli.config(); + cli_conf.tls_cert_filename = CLIENT2_CERT_FILE; + cli_conf.tls_cert_password = CLIENT2_CERT_FILE_PWD; + testDiag("cli.reconfigure()"); + cli.reconfigure(cli_conf); + + testThrows([this, &sub, &evt] { pop(sub, evt); }); + testDiag("Disconnect"); + + try { + (void)pop(sub, evt); + testFail("Missing expected Connected"); + } catch (client::Connected& e) { + testOk1(e.cred && e.cred->isTLS); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 1) + } catch (...) { + testFail("Unexpected exception instead of Connected"); + } + testDiag("Reconnect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT2); + TEST_COUNTER_EQ(ioc, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(client2, 1) + } + + /** + * @brief Tests that new configuration is applied to all server connections when the server reconfigure is executed. + * + * Here we use the SERVER1 and IOC1 certificates for the server and check that after a reconfigure the + * tls session is re-established but using the new configuration. + * + * As this uses the Mock PVACMS we verify that it checks certificate status before using the certificates + */ + void testServerReconfig() { + testShow() << __func__; + RESET_COUNTER(server1) + RESET_COUNTER(client1) + RESET_COUNTER(ioc) + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + serv_conf.tls_disable_stapling = false; + + auto serv(serv_conf.build().addSource(WHO_AM_I_PV, std::make_shared())); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + + auto cli(cli_conf.build()); + + serv.start(); + + epicsEvent evt; + auto sub(cli.monitor(WHO_AM_I_PV).maskConnected(false).maskDisconnected(false).event([&evt](client::Subscription&) { evt.signal(); }).exec()); + Value update; + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, TLS_METHOD_STRING); + testEq(e.cred->account, CERT_CN_SERVER1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 0) + } + testDiag("Connect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 0) + + serv_conf = serv.config(); + serv_conf.tls_cert_filename = IOC1_CERT_FILE; + testDiag("serv.reconfigure()"); + serv.reconfigure(serv_conf); + + testThrows([this, &sub, &evt] { pop(sub, evt); }); + testDiag("Disconnect"); + + try { + pop(sub, evt); + testFail("Unexpected success"); + testSkip(2, "oops"); + } catch (client::Connected& e) { + testTrue(e.cred->isTLS); + testEq(e.cred->method, TLS_METHOD_STRING); + testEq(e.cred->account, CERT_CN_IOC1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 1) + } + testDiag("Reconnect"); + + update = pop(sub, evt); + testEq(update[TEST_PV_FIELD].as(), TLS_METHOD_STRING "/" CERT_CN_CLIENT1); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + TEST_COUNTER_EQ(ioc, 1) + } + + /** + * @brief Test that if client requests stapling but server does not send it + * communication is established by out of band status request to CMS + */ + void testClientStaplingNoServerStapling() { + testShow() << __func__; + RESET_COUNTER(server1) + RESET_COUNTER(client1) + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + auto serv(serv_conf.build().addPV(TEST_PV, mbox)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + cli_conf.tls_disable_stapling = false; + auto cli(cli_conf.build()); + + mbox.open(initial.update("value", 42)); + serv.start(); + + auto conn(cli.connect(TEST_PV).onConnect([](const client::Connected& c) { testTrue(c.cred && c.cred->isTLS); }).exec()); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 0) + + auto reply(cli.get(TEST_PV).exec()->wait(5.0)); + testEq(reply["value"].as(), 42); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + + conn.reset(); + } + + /** + * @brief Test that if server sends stapling but client is not expecting it + * communication is established by out of band status request to CMS + */ + void testServerStaplingNoClientStapling() { + testShow() << __func__; + RESET_COUNTER(server1) + RESET_COUNTER(client1) + auto initial(nt::NTScalar{TypeCode::Int32}.create()); + auto mbox(server::SharedPV::buildReadonly()); + + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = SERVER1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + serv_conf.tls_disable_stapling = false; + auto serv(serv_conf.build().addPV(TEST_PV, mbox)); + + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + cli_conf.tls_disable_stapling = true; + auto cli(cli_conf.build()); + + mbox.open(initial.update("value", 42)); + serv.start(); + + auto conn(cli.connect(TEST_PV).onConnect([](const client::Connected& c) { testTrue(c.cred && c.cred->isTLS); }).exec()); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 0) + + auto reply(cli.get(TEST_PV).exec()->wait(5.0)); + testEq(reply["value"].as(), 42); + TEST_COUNTER_EQ(server1, 1) + TEST_COUNTER_EQ(client1, 1) + + conn.reset(); + } + + /** + * @brief This test checks that tls connections are prohibited when CMS is unavailable but configuration requires it + * + * The Mock PVACMS must be previously stopped prior to this test + * + */ + void testCMSUnavailable() { + testShow() << __func__; + // Create a test PV and set value to 42 + auto test_pv_value(nt::NTScalar{TypeCode::Int32}.create()); + auto test_pv(server::SharedPV::buildReadonly()); + test_pv.open(test_pv_value.update(TEST_PV_FIELD, 42)); + { + // Configure server with status checking enabled + auto serv_conf(server::Config::isolated()); + serv_conf.tls_cert_filename = IOC1_CERT_FILE; + serv_conf.tls_disable_status_check = false; + serv_conf.tls_throw_if_no_cert = true; + serv_conf.tls_disable_stapling = false; + + // Test that server will not start because the Mock CMS is not running + try { + auto serv(serv_conf.build().addPV(TEST_PV, test_pv)); + testFail("Unexpected successful creation of server"); + } catch ( std::runtime_error &e) { + testStrEq("Unable to contact PVACMS: Waiting for PVACMS to report status for cert " IOC1_CERT_FILE, e.what()); + } + + // Now lets try again with status checking and stapling disabled so we can test the client + serv_conf.tls_disable_status_check = true; + serv_conf.tls_disable_stapling = true; + auto serv(serv_conf.build().addPV(TEST_PV1, test_pv)); + // Start the server + serv.start(); + + // Configure client with status checking enabled + epicsEvent client_started_evt; + auto cli_conf(serv.clientConfig()); + cli_conf.tls_cert_filename = CLIENT1_CERT_FILE; + cli_conf.tls_disable_status_check = false; + cli_conf.tls_disable_stapling = false; + auto cli(cli_conf.build()); + + try { + auto val(cli.get(TEST_PV1).exec()->wait(1.0)); + testFail("Unexpected Success"); + } catch (std::exception &e) { + testStrEq("Timeout", e.what()); + } + + // Try again with a monitor + auto sub(cli.monitor(TEST_PV1) + .maskConnected(false) + .maskDisconnected(false) + .event([&client_started_evt](client::Subscription&) { client_started_evt.signal(); }) + .exec()); + + // Wait for the client to fail to connect + testTrue(!client_started_evt.wait(1.0)); + } + + { + // Configure server with status checking and stapling disabled + auto serv_conf2(server::Config::isolated()); + serv_conf2.tls_cert_filename = IOC1_CERT_FILE; + serv_conf2.tls_disable_status_check = true; + serv_conf2.tls_disable_stapling = true; + auto serv2(serv_conf2.build().addPV(TEST_PV2, test_pv)); + + // Configure client with status checking disabled + auto cli_conf2(serv2.clientConfig()); + cli_conf2.tls_cert_filename = CLIENT1_CERT_FILE; + auto cli2(cli_conf2.build()); + + // Start the server + serv2.start(); + + // Try to get the value of the PV + auto reply(cli2.get(TEST_PV2).exec()->wait()); + testEq(reply[TEST_PV_FIELD].as(), 42); + } + } +}; + +} // namespace + +MAIN(testtlswithcmsandstapling) { + // Initialize SSL + pvxs::ossl::SSLContext::sslInit(); + + testPlan(267); + testSetup(); + logger_config_env(); + auto tester = new Tester(); + tester->createCertStatuses(); + tester->makeStatusResponses(); + tester->startMockCMS(); + try { + tester->testGetIntermediate(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testClientReconfig(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testServerReconfig(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testClientStaplingNoServerStapling(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testServerStaplingNoClientStapling(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->stopMockCMS(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + try { + tester->testCMSUnavailable(); + } catch (std::runtime_error& e) { + testFail("FAILED with errors: %s\n", e.what()); + } + delete (tester); + cleanup_for_valgrind(); + return testDone(); +} diff --git a/tools/Makefile b/tools/Makefile index 4e3eef48c..15d451652 100644 --- a/tools/Makefile +++ b/tools/Makefile @@ -3,8 +3,7 @@ TOP=.. include $(TOP)/configure/CONFIG # cfg/ sometimes isn't correctly included due to a Base bug # so we do here (maybe again) as workaround -include $(TOP)/configure/CONFIG_PVXS_MODULE -include $(TOP)/configure/CONFIG_PVXS_VERSION +-include $(wildcard $(TOP)/cfg/CONFIG*) #---------------------------------------- # ADD MACRO DEFINITIONS AFTER THIS LINE #============================= @@ -12,6 +11,23 @@ include $(TOP)/configure/CONFIG_PVXS_VERSION # access to private headers USR_CPPFLAGS += -I$(TOP)/src +ifeq ($(EVENT2_HAS_OPENSSL),YES) +SRC_DIRS += $(TOP)/certs +SRC_DIRS += $(TOP)/src +USR_CPPFLAGS += -DPVXS_ENABLE_OPENSSL +USR_CPPFLAGS += -I$(TOP)/certs -I$(TOP)/bundle/CLI11/include + +PROD += pvxcert +pvxcert_SRCS += cert.cpp +pvxcert_SRCS += p12filefactory.cpp +pvxcert_SRCS += pemfilefactory.cpp +pvxcert_SRCS += certfilefactory.cpp +pvxcert_SRCS += certfactory.cpp +pvxcert_SRCS += certstatus.cpp +pvxcert_SRCS += certstatusmanager.cpp + +endif # EVENT2_HAS_OPENSSL + PROD_LIBS += pvxs Com PROD += pvxvct @@ -41,7 +57,7 @@ pvxmshim_SRCS += mshim.cpp #=========================== include $(TOP)/configure/RULES -include $(TOP)/configure/RULES_PVXS_MODULE +-include $(wildcard $(TOP)/cfg/RULES*) #---------------------------------------- # ADD RULES AFTER THIS LINE diff --git a/tools/call.cpp b/tools/call.cpp index fa171ef14..e2c0937a8 100644 --- a/tools/call.cpp +++ b/tools/call.cpp @@ -65,7 +65,7 @@ int main(int argc, char *argv[]) break; default: usage(argv[0]); - std::cerr<<"\nUnknown argument: "</dev/null + + # Create a P12 file containing only the private key + openssl pkcs12 -export -inkey "$PRIVATE_KEY_FILE" -nocerts -out "$P12_KEY_FILE" -passout pass:$P12_PASSWORD + + # Remove the temporary PEM private key file + rm "$PRIVATE_KEY_FILE" + + # Set secure file permissions for the P12 file and public key file + chmod 400 "$P12_KEY_FILE" "$PUBLIC_KEY_FILE" +fi +pub_key=$(cat $PUBLIC_KEY_FILE) + +echo pvxcall "CERT:CREATE" type="x509" name="$name" country="US" organization="$org" organization_unit="" not_before=$not_before not_after=$not_after usage=$usage +result=$(pvxcall "CERT:CREATE" type="x509" name="$name" country="US" organization="$org" organization_unit="" not_before=$not_before not_after=$not_after usage=$usage pub_key="${pub_key}") + +# Capture the exit status of the pvxcall command +status=$? + +# Check if the pvxcall command was successful +if [ $status -ne 0 ]; then + exit $status +fi + + +# Extract the certificates block using awk and sed +certs_block=$(echo "$result" | awk -F 'string cert = "' '{print $2}' | sed 's/" struct.*//' | sed 's/\\n/\n/g' | sed '/^\s*$/d' | sed '/^\".*$/d') + +# Split the certificates block into first and root certificates +cert=$(echo "$certs_block" | awk 'BEGIN {RS="-----END CERTIFICATE-----\n"} NR==1 {print $0 "-----END CERTIFICATE-----"}') +root_cert=$(echo "$certs_block" | awk 'BEGIN {RS="-----END CERTIFICATE-----\n"} {last=$0} END {print last "-----END CERTIFICATE-----"}') + +# Extract the serial number using awk +serial=$(echo "$result" | awk -F 'uint64_t serial = ' '{print $2}' | awk '{print $1}' | sed '/^\s*$/d') + +# Extract the issuer using awk +issuer=$(echo "$result" | awk -F 'string issuer = "' '{print $2}' | awk -F '"' '{print $1}' | sed '/^\s*$/d') + + +# Save the certificate to a temporary file +echo "$cert" > "$CERT_FILE" + +# Save the root certificate to a temporary file +echo "$root_cert" > "$ROOT_FILE" + +# Combine the first certificate and root certificate into a single chain file +cat "$CERT_FILE" "$ROOT_FILE" > "$CHAIN_FILE" + +# Create the PKCS#12 (.p12) file +openssl pkcs12 -export \ + -in "$CERT_FILE" \ + -inkey <(openssl pkcs12 -in "$P12_KEY_FILE" -nocerts -nodes -passin pass:$P12_PASSWORD) \ + -certfile "$CHAIN_FILE" \ + -out "$P12_FILE" \ + -passout pass:$P12_PASSWORD + +echo "Created: ${P12_FILE}. ${target} certificate: ${issuer}:${serial}" diff --git a/tools/cert.cpp b/tools/cert.cpp new file mode 100644 index 000000000..776fcd115 --- /dev/null +++ b/tools/cert.cpp @@ -0,0 +1,265 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include + +#include "certfactory.h" +#include "certfilefactory.h" +#include "certstatusmanager.h" +#include "p12filefactory.h" +#include "pemfilefactory.h" + +using namespace pvxs; + +namespace { + +DEFINE_LOGGER(certslog, "pvxs.certs.tool"); + +void setEcho(bool enable) { + struct termios tty {}; + tcgetattr(STDIN_FILENO, &tty); + if (!enable) { + tty.c_lflag &= ~ECHO; + } else { + tty.c_lflag |= ECHO; + } + tcsetattr(STDIN_FILENO, TCSANOW, &tty); +} + +// Helper function to convert string to enum +Value::Fmt::format_t stringToFormat(const std::string& formatStr) { + if (formatStr == "delta") { + return Value::Fmt::Delta; + } else if (formatStr == "tree") { + return Value::Fmt::Tree; + } else { + throw std::invalid_argument("Invalid format type"); + } +} + +} // namespace + +enum CertAction { STATUS, INSTALL, APPROVE, DENY, REVOKE }; +std::string actionToString(CertAction& action) { + return (action == STATUS ? "Get Status" + : action == INSTALL ? "Install Root Certificate" + : action == APPROVE ? "Approve" + : action == REVOKE ? "Revoke" + : "Deny"); +} + +int main(int argc, char* argv[]) { + try { + logger_config_env(); // from $PVXS_LOG + auto conf = client::Config::fromEnv(); + + CLI::App app{"Certificate management utility for PVXS"}; + + // Variables to store options + bool verbose{false}; + bool debug{false}; + bool show_version{false}; + bool password_flag{false}; + std::string cert_file, password; + Value::Fmt::format_t format = Value::Fmt::Delta; + std::string format_str; + uint64_t arrLimit = 20; + CertAction action{STATUS}; + bool install{false}, approve{false}, revoke{false}, deny{false}; + + // Add a positional argument + std::string issuer_serial_string; + app.add_option("cert_id", issuer_serial_string, "Certificate ID")->required(false); + + // Define options + app.add_option("-w,--timeout", conf.request_timeout_specified, "Operation timeout in seconds")->default_val(5.0); + app.add_flag("-v,--verbose", verbose, "Make more noise"); + app.add_flag("-d,--debug", debug, "Shorthand for $PVXS_LOG=\"pvxs.*=DEBUG\". Make a lot of noise."); + app.add_option("-f,--file", cert_file, "The certificate file to read if no Certificate ID specified"); + app.add_flag("-p,--password", password_flag, "Prompt for password"); + app.add_flag("-V,--version", show_version, "Print version and exit."); + app.add_option("-#,--limit", arrLimit, "Maximum number of elements to print for each array field. Set to zero 0 for unlimited")->default_val(20); + app.add_option("-F,--format", format_str, "Output format mode: delta, tree"); + + // Action flags in mutually exclusive group + auto action_group = app.add_option_group("Actions")->required(false); + action_group->add_flag("-I,--install", install, "Download and install the root certificate"); + action_group->add_flag("-A,--approve", approve, "APPROVE the certificate (ADMIN ONLY)"); + action_group->add_flag("-R,--revoke", revoke, "REVOKE the certificate (ADMIN ONLY)"); + action_group->add_flag("-D,--deny", deny, "DENY the pending certificate (ADMIN ONLY)"); + + CLI11_PARSE(app, argc, argv); + + if (show_version) { + if (argc > 2) { + std::cerr << "Error: -V option cannot be used with any other options.\n"; + return 1; + } + std::cout << pvxs::version_information; + return 0; + } + + if (password_flag && cert_file.empty()) { + log_err_printf(certslog, "Error: -p must only be used with -f.%s", "\n"); + return 1; + } + + if (!cert_file.empty() && (install || approve || revoke || deny)) { + log_err_printf(certslog, "Error: -I, -A, -R, or -D cannot be used with -f.%s", "\n"); + return 2; + } + + if (!format_str.empty()) { + format = stringToFormat(format_str); + } + + // Handle the flags after parsing + if (debug) logger_level_set("pvxs.*", Level::Debug); + if (password_flag) { + std::cout << "Enter password: "; + setEcho(false); + std::getline(std::cin, password); + setEcho(true); + std::cout << std::endl; + } + + if (install) action = INSTALL; + if (approve) action = APPROVE; + if (revoke) action = REVOKE; + if (deny) action = DENY; + + auto ctxt = conf.build(); + + if (verbose) std::cout << "Effective config\n" << conf; + + std::list> ops; + + epicsEvent done; + + std::string cert_id, root_id; + + if (!cert_file.empty()) { + try { + auto cert_data = certs::IdFileFactory::create(cert_file, password)->getCertDataFromFile(); + cert_id = certs::CertStatusManager::getStatusPvFromCert(cert_data.cert); + } catch (std::exception& e) { + log_err_printf(certslog, "Unable to get cert from cert file: %s\n", e.what()); + return 3; + } + } else { + if (action == INSTALL) { + root_id = "CERT:ROOT"; + } else { + cert_id = "CERT:STATUS:" + issuer_serial_string; + } + } + + try { + if (action != INSTALL) std::cout << actionToString(action) << " ==> " << ((!root_id.empty()) ? root_id : cert_id) << "\n"; + switch (action) { + case INSTALL: { + ops.push_back(ctxt.get(root_id) + .result([root_id, &done](client::Result&& result) { + Indented I(std::cout); + auto value = result(); + uint64_t serial = value["serial"].as(); + auto name = value["name"].as(); + auto issuer = value["issuer"].as(); + auto org = value["org"].as(); + auto org_unit = value["org_unit"].as(); + auto pem_string = value["cert"].as(); + + std::cout << "Installing Root CA Certificate" << std::endl; + certs::PEMFileFactory::createRootPemFile(pem_string, true); + std::cout << "\t------------------------------------------------\n" + << "\tNAME:\t\t\t" << name << "\n\tORGANIZATION:\t\t" << org << "\n\tORGANIZATIONAL UNIT:\t" << org_unit + << "\n\tISSUER:\t\t\t" << issuer << "\n\tSERIAL:\t\t\t" << serial << std::endl; + + done.signal(); + }) + .exec()); + } break; + case STATUS: { + ops.push_back(ctxt.get(cert_id) + .result([cert_id, &done, format, arrLimit](client::Result&& result) { + Indented I(std::cout); + std::cout << result().format().format(format).arrayLimit(arrLimit); + done.signal(); + }) + .exec()); + } break; + case APPROVE: { + ops.push_back(ctxt.put(cert_id) + .set("state", "APPROVED") + .result([cert_id, &done, format, arrLimit](client::Result&& result) { + Indented I(std::cout); + if (result) std::cout << result().format().format(format).arrayLimit(arrLimit); + done.signal(); + }) + .exec()); + } break; + case DENY: { + ops.push_back(ctxt.put(cert_id) + .set("state", "DENIED") + .result([cert_id, &done, format, arrLimit](client::Result&& result) { + Indented I(std::cout); + if (result) std::cout << result().format().format(format).arrayLimit(arrLimit); + done.signal(); + }) + .exec()); + } break; + case REVOKE: { + ops.push_back(ctxt.put(cert_id) + .set("state", "REVOKED") + .result([cert_id, &done, format, arrLimit](client::Result&& result) { + Indented I(std::cout); + if (result) std::cout << result().format().format(format).arrayLimit(arrLimit); + done.signal(); + }) + .exec()); + } break; + } + } catch (std::exception& e) { + log_err_printf(certslog, "Unable to %s ==> %s %s", actionToString(action).c_str(), cert_id.c_str(), "\n"); + ctxt.close(); + return 3; + } + + // expedite search after starting all requests + ctxt.hurryUp(); + + SigInt sig([&done]() { done.signal(); }); + + bool waited = done.wait(conf.request_timeout_specified); + ops.clear(); // implied cancel + + if (!waited) { + log_err_printf(certslog, "Timeout%s", "\n"); + return 4; + + } else if (issuer_serial_string.empty()) { + return 0; + + } else { + if (verbose) log_err_printf(certslog, "Interrupted.%s", "\n"); + return 5; + } + } catch (std::exception& e) { + log_err_printf(certslog, "Error: %s%s", e.what(), "\n"); + return 6; + } +} diff --git a/tools/get.cpp b/tools/get.cpp index e79135263..70b4af3ac 100644 --- a/tools/get.cpp +++ b/tools/get.cpp @@ -9,6 +9,9 @@ #include #include +#include +#include +#include #include #include @@ -40,7 +43,7 @@ void usage(const char* argv0) ; } -} +} // namespace int main(int argc, char *argv[]) { @@ -50,11 +53,13 @@ int main(int argc, char *argv[]) bool verbose = false; std::string request; Value::Fmt::format_t format = Value::Fmt::Delta; - auto arrLimit = uint64_t(-1); + auto arrLimit = uint64_t(20); + std::string options; + options = "hVvdw:r:#:F:"; { int opt; - while ((opt = getopt(argc, argv, "hVvdw:r:#:F:")) != -1) { + while ((opt = getopt(argc, argv, options.c_str())) != -1) { switch(opt) { case 'h': usage(argv[0]); @@ -88,16 +93,19 @@ int main(int argc, char *argv[]) break; default: usage(argv[0]); - std::cerr<<"\nUnknown argument: "<> ops; diff --git a/tools/info.cpp b/tools/info.cpp index 07330c74f..94ff521d1 100644 --- a/tools/info.cpp +++ b/tools/info.cpp @@ -67,16 +67,19 @@ int main(int argc, char *argv[]) break; default: usage(argv[0]); - std::cerr<<"\nUnknown argument: "<> ops; diff --git a/tools/list.cpp b/tools/list.cpp index f35065cc9..a6f5a351e 100644 --- a/tools/list.cpp +++ b/tools/list.cpp @@ -100,7 +100,7 @@ int main(int argc, char *argv[]) break; default: usage(argv[0]); - std::cerr<<"\nUnknown argument: "< Output format mode: delta, tree\n" +#ifdef PVXS_ENABLE_OPENSSL + " -w Timeout for certificate status verification if configured. default 5 sec.\n" +#endif ; } @@ -47,14 +50,19 @@ int main(int argc, char *argv[]) { try { logger_config_env(); // from $PVXS_LOG + double timeout{5.0}; bool verbose = false; std::string request; Value::Fmt::format_t format = Value::Fmt::Delta; - auto arrLimit = uint64_t(-1); + auto arrLimit = uint64_t(20); { int opt; +#ifdef PVXS_ENABLE_OPENSSL + while ((opt = getopt(argc, argv, "hVvw:dr:#:F:")) != -1) { +#else while ((opt = getopt(argc, argv, "hVvdr:#:F:")) != -1) { +#endif switch(opt) { case 'h': usage(argv[0]); @@ -69,6 +77,11 @@ int main(int argc, char *argv[]) case 'd': logger_level_set("pvxs.*", Level::Debug); break; +#ifdef PVXS_ENABLE_OPENSSL + case 'w': + timeout = parseTo(optarg); + break; +#endif case 'r': request = optarg; break; @@ -86,16 +99,21 @@ int main(int argc, char *argv[]) break; default: usage(argv[0]); - std::cerr<<"\nUnknown argument: "<> workqueue(argc-optind+1); diff --git a/tools/mshim.cpp b/tools/mshim.cpp index 18257c5b7..026138255 100644 --- a/tools/mshim.cpp +++ b/tools/mshim.cpp @@ -57,7 +57,7 @@ SockEndpoint parseEP(const char* optarg, const server::Config& conf) { SockEndpoint ep; try { - ep = SockEndpoint(optarg, conf.udp_port); + ep = SockEndpoint(optarg, nullptr, conf.udp_port); }catch(std::exception& e){ std::cerr<<"Error: Invalid group spec. '"<