OK writing a C++ wrapper on top of openSSL.
Setting up the SSL_CTX*
object. There are a whole bunch of extra functions to specify functionality on how this object works.
I wanted a way to logically organize them and correctly initialize the object in the constructor. So I have set up the constructor to take 5 parameters that describe how to set up the object correctly.
If I was simply to write out this object it would look like this:
// Note: This first part here is just to show the basic set up.
// Below I will provide two alternative implementations.
// It is the implementations below I am looking for help on.
//
// This is just to give context.
enum class SSLMethodType {Client, Server};
class SSLctxBuilder;
class ProtocolInfo;
class CipherInfo;
class CertificateInfo;
class CertifcateAuthorityInfo;
class ClientCAListInfo;
class SSLctx
{
friend class SSLctxBuilder;
private:
SSL_CTX* ctx; // This is the openSSL
// object I am wrapping.
public:
SSLctx(
SSLMethodType methodType,
ProtocolInfo const& protocol,
CipherInfo const& cipherList,
CertificateInfo const& certificateInfo,
CertifcateAuthorityInfo const& certifcateAuthority,
ClientCAListInfo const& clinetCAList
);
~SSLctx();
SSLctx(SSLctx const&) = delete;
SSLctx& operator=(SSLctx const&) = delete;
};
What I want to do is all ow the user to provide only the parameters they want to override and all the others to default. The above objects can all be default constructed and provide a reasonable set of values.
Trying to do this with default values and multiple constructors becomes obnoxious to read and maintain. So I was looking at ways to make it easy to write and easy to use. I though of two techniques.
So the review is on the following code and the two alternative implementations:
The types in the parameters are relatively simple so I will provide them here for quick context:
// When specify valid protocols.
// Either use the default safe values.
// Or you must use an explicit min and max acceptable range.
enum Protocol { TLS_1_0, TLS_1_1, TLS_1_2, TLS_1_3 };
class ProtocolInfo
{
Protocol minProtocol = TLS_1_2;
Protocol maxProtocol = TLS_1_3;
public:
ProtocolInfo()
: ProtocolInfo(TLS_1_2, TLS_1_3)
{}
ProtocolInfo(Protocol minVal, Protocol maxVal)
: minProtocol(minVal)
, maxProtocol(maxVal)
};
// CipherInfo list of acceptable ciphers that you want to use.
// cipherList: Used by TLS_1_2 and below
// cipherSuite: Used by TLS_1_3 and above
// Don't need any constructor allow the user to override as applicable.
struct CipherInfo
{
std::string cipherList = "ECDHE-ECDSA-AES128-GCM-SHA256" ":"
"ECDHE-RSA-AES128-GCM-SHA256" ":"
"ECDHE-ECDSA-AES256-GCM-SHA384" ":"
"ECDHE-RSA-AES256-GCM-SHA384" ":"
"ECDHE-ECDSA-CHACHA20-POLY1305" ":"
"ECDHE-RSA-CHACHA20-POLY1305" ":"
"DHE-RSA-AES128-GCM-SHA256" ":"
"DHE-RSA-AES256-GCM-SHA384";
std::string cipherSuite = "TLS_AES_256_GCM_SHA384" ":"
"TLS_CHACHA20_POLY1305_SHA256" ":"
"TLS_AES_128_GCM_SHA256";
};
// Service specific certificate.
// Either default construct (which means no server certificate)
// or you must provide the Certificate and Key.
// If your key is password protected a user function that is called
// to retrieve the password for the key (default no password).
struct CertificateInfo
{
std::string certificateFileName;
std::string keyFileName;
GetPasswordFunc getPassword;
public:
CertificateInfo();
CertificateInfo(std::string const& certificateFileName, std::string const& keyFileName, GetPasswordFunc&& getPassword = [](int){return "";});
};
The next one is slightly convoluted as openSSL
provides 3 mechanisms to specify the set of valid trusted root CA certificates: File
, Dir
or Store
. But they all work in the same sort of way. You can load the "Default" system provided trusted root certificates or you can provide a string that is a "File/Dir/URI" that specifies where to find the object. Of course each method has its own unique C function to call:
enum AuthorityType { File, Dir, Store };
template<AuthorityType A>
struct CertifcateAuthorityDataInfo
{
bool loadDefault = false; // Do we want the system defaults.
std::vector<std::string> items; // they all work with multiple valiues.
};
struct CertifcateAuthorityInfo
{
CertifcateAuthorityDataInfo<File> file;
CertifcateAuthorityDataInfo<Dir> dir;
CertifcateAuthorityDataInfo<Store> store;
};
Finally the set of root CA certificates that would be returned by server to client on the initial handshake, telling th eclient the root CA that we trust.
template<AuthorityType A>
struct ClientCAListDataInfo
{
std::vector<std::string> items;
};
struct ClientCAListInfo
{
bool verifyClientCA = false;
ClientCAListDataInfo<File> file;
ClientCAListDataInfo<Dir> dir;
ClientCAListDataInfo<Store> store;
};
So the first implementation uses named parameters.
There are two compromises I had to make here:
- I had to have a separate
struct
with all the parameters that I wanted to name. - When I have a list of things I had to bunch them up (eg Items) I have name all the value up front at the same time.
Code:
struct SSLctxInit
{
ProtocolInfo protocol;
CipherInfo cipherList;
CertificateInfo certificateInfo;
CertifcateAuthorityInfo certifcateAuthority;
ClientCAListInfo clientCAList;
};
class SSLctx
{
private:
SSL_CTX* ctx;
public:
SSLctx(SSLMethodType methodType, SSLctxInit const& init = SSLctxInit{});
~SSLctx();
SSLctx(SSLctx const&) = delete;
SSLctx& operator=(SSLctx const&) = delete;
};
Usage:
SSLctx ctx(SSLMethodType::Client); // Simple use all the defaults.
// Default but override the acceptable protocols.
SSLctx ctx(SSLMethodType::Server, {
.protocol = ProtocolInfo{TLS_1_0, TLS_1_2}
});
// Default but a list of trusted root CA certificates
SSLctx ctx(SSLMethodType::Server,{
.certifcateAuthority = {.file = {true, {"path1", "path2", "path3"}}}
});
// The other examples are very similar.
// But you can override each one in a sort of trivial way.
// Default but a list of trusted root CA certificates
SSLctx ctx(SSLMethodType::Server,{
.protocol = ProtocolInfo{TLS_1_0, TLS_1_2},
.certifcateAuthority = {
.file = {.items = {"path1", "path2", "path3"}},
.dir = {true}
}
});
The alternative implementation I was considering was to use the "Builder" pattern that I have seen being used in Java a lot. The advantage in Java is that their tooling allows the "Builder classes to be auto generated". So in C++ this becomes a bit more boiler plate code.
Another disadvantage is that I have not seen this pattern used much in C++ code. What I like about it is the ability to use methods (named functions) to initialize each of the parameters individually even if they are multiple levels deep.
class SSLctx
{
friend class SSocket;
friend class SSLctxBuilder;
private:
SSL_CTX* ctx;
// Notice I made this private so you have to use a builder.
// Not sure that is the correct move yet.
SSLctx(SSLMethodType methodType,
ProtocolInfo protocol,
CipherInfo const& cipherList,
CertificateInfo const& certificateInfo,
CertifcateAuthorityInfo const& certifcateAuthority,
ClientCAListInfo const& clinetCAList);
public:
~SSLctx();
SSLctx(SSLctx const&) = delete;
SSLctx& operator=(SSLctx const&) = delete;
};
// The Builder is like the SSLctxInit in the last example.
// but has functions to initialize the objects in a simple named way.
// The calling `build()` creates the object.
class SSLctxBuilder
{
SSLMethodType method;
ProtocolInfo protocolRange;
CipherInfo cipherList;
CertificateInfo certificate;
CertifcateAuthorityInfo certifcateAuthority;
ClientCAListInfo clientCAList;
public:
SSLctxBuilder(SSLMethodType method): method(method) {}
SSLctxBuilder& setProtocolInfo(ProtocolInfo info) {protocolRange = std::move(info);return *this;}
SSLctxBuilder& setCipherInfo(CipherInfo&& info) {cipherList = std::move(info);return *this;}
SSLctxBuilder& addCertificateInfo(CertificateInfo&& info) {certificate = std::move(info);return *this;}
SSLctxBuilder& addDefaultCertifcateAuthorityFile() {certifcateAuthority.file.loadDefault = true;return *this;}
SSLctxBuilder& addDefaultCertifcateAuthorityDir() {certifcateAuthority.dir.loadDefault = true;return *this;}
SSLctxBuilder& addDefaultCertifcateAuthorityStore() {certifcateAuthority.store.loadDefault = true;return *this;}
SSLctxBuilder& addCertifcateAuthorityFile(std::string const& file) {certifcateAuthority.file.items.push_back(file);return *this;}
SSLctxBuilder& addCertifcateAuthorityDir(std::string const& dir) {certifcateAuthority.dir.items.push_back(dir);return *this;}
SSLctxBuilder& addCertifcateAuthorityStore(std::string const& store){certifcateAuthority.store.items.push_back(store);return *this;}
SSLctxBuilder& validateClientCA() {clientCAList.verifyClientCA = true;return *this;}
SSLctxBuilder& addFileToClientCAList(std::string const& file) {clientCAList.file.items.push_back(file);return *this;}
SSLctxBuilder& addDirToClientCAList(std::string const& dir) {clientCAList.dir.items.push_back(dir);return *this;}
SSLctxBuilder& addStoreToClientCAList(std::string const& store) {clientCAList.store.items.push_back(store);return *this;}
SSLctx build()
{
return SSLctx{method, protocolRange, cipherList, certificate, certifcateAuthority, clientCAList};
}
};
This changes usage to (Same three examples as above).
Usage:
SSLctx ctx = SSLctxBuilder(SSLMethodType::Client).build(); // Simple use all the defaults.
// Default but override the acceptable protocols.
SSLctx ctx = SSLctxBuilder(SSLMethodType::Server)
.setProtocolInfo({TLS_1_0, TLS_1_2})
.build();
// Default but a list of trusted root CA certificates
SSLctx ctx = SSLctxBuilder(SSLMethodType::Server)
.addDefaultCertifcateAuthorityFile() // Notice break the list of paths into multiple calls.
.addCertifcateAuthorityFile("path1")
.addCertifcateAuthorityFile("path2")
.addCertifcateAuthorityFile("path3")
.build();
// The other examples are very similar.
// But you can override each one in a sort of trivial way.
// Default but a list of trusted root CA certificates
SSLctx ctx = SSLctxBuilder(SSLMethodType::Server)
.setProtocolInfo({TLS_1_0, TLS_1_2})
.addCertifcateAuthorityFile("path1") // Do files and dir as simply as just files.
.addCertifcateAuthorityFile("path2")
.addCertifcateAuthorityFile("path3")
.addDefaultCertifcateAuthorityDir()
.build();