3. The OpenSSL 3 Provider Architecture

This chapter describes the mechanism by which a loadable module supplies algorithms to OpenSSL 3. It is deliberately concrete: the design chapters later in the paper specify dispatch tables and parameter arrays, and those specifications are only meaningful against an accurate account of the convention they follow.

3.1 What a provider is

The OpenSSL glossary defines a provider as "a component that groups together algorithm implementations", which may come from OpenSSL itself or from third parties. Operationally, a provider is a shared object exporting a single symbol, OSSL_provider_init, which the core calls at load time. Everything else the provider offers is reached through function pointers returned from that call.

Three ideas do the work in this architecture: the dispatch table, the parameter array, and the property query. Each replaces something that in the ENGINE era was a struct field or a global.

3.2 Dispatch tables

A dispatch table is an array of OSSL_DISPATCH entries, each pairing a numeric function identifier with a function pointer:

typedef struct ossl_dispatch_st {
    int function_id;
    void (*function)(void);
} OSSL_DISPATCH;

The array is terminated by a zero entry. The core walks it, recognises the identifiers it knows, and ignores the rest. This single convention supplies the architecture's two most important properties.

First, version independence. A provider compiled against an older set of identifiers is still usable: the core finds the entries it recognises and treats the absent ones as unimplemented. A provider compiled against a newer set does not break an older core, which simply ignores identifiers it does not know. Contrast the ENGINE mechanism, where the interface was the layout of RSA_METHOD and similar structures, so adding a field was a compatibility event affecting every engine.

Second, partial implementation is legitimate. A provider need not implement an entire operation. It supplies what it supplies; the core's fetching machinery decides whether what is offered is sufficient for the request at hand. This is what allows a provider to offer a single algorithm without implementing anything else.

The initialisation function receives the core's own dispatch table and returns the provider's:

int OSSL_provider_init(const OSSL_CORE_HANDLE *handle,
                       const OSSL_DISPATCH *in,
                       const OSSL_DISPATCH **out,
                       void **provctx);

The in table is how the provider reaches back into the core — for memory allocation, for error reporting, for reading configuration parameters. The out table is how the core reaches the provider. The symmetry is the point: neither side links against the other's symbols.

3.3 The provider's own dispatch table

A minimal provider returns a table containing a teardown function, a query function, and parameter accessors. The query function is the entry point that matters for algorithm supply:

static const OSSL_ALGORITHM *query(void *provctx, int operation_id,
                                   int *no_cache);

The core calls it with an operation identifier — digest, cipher, key exchange, signature, key management, and so on — and the provider returns an array of OSSL_ALGORITHM entries for that operation, or NULL if it offers none:

typedef struct ossl_algorithm_st {
    const char *algorithm_names;     /* colon-separated list */
    const char *property_definition; /* e.g. "provider=edu" */
    const OSSL_DISPATCH *implementation;
    const char *algorithm_description;
} OSSL_ALGORITHM;

Two details of this structure shape the design chapters. The names field is a colon-separated list, so one implementation may answer to several names — which is how an algorithm can be reachable by both a formal name and a common alias. The property definition is a string of key/value pairs, and it is the sole input the provider contributes to the selection policy described in §3.5.

3.4 Parameter arrays

Data crossing the core/provider boundary travels as OSSL_PARAM arrays rather than as struct fields:

typedef struct ossl_param_st {
    const char *key;
    unsigned int data_type;
    void *data;
    size_t data_size;
    size_t return_size;
} OSSL_PARAM;

An algorithm advertises which parameters it will answer through a "gettable" function, and the caller supplies an array to be filled. The mechanism is verbose compared with reading a struct member, and this verbosity is the price of the version independence described above: a parameter the receiver does not recognise is skipped rather than misinterpreted.

For this paper the mechanism is central, because it is how the demand surface of Chapter 2 is satisfied. The record layer learns an AEAD's key, IV and tag lengths by requesting parameters; the key schedule learns a hash's output length the same way. A provider that fails to answer a parameter the consumer requires does not merely lose a feature — the algorithm becomes unusable in that role.

3.5 Fetching and property queries

In OpenSSL 3 an algorithm is obtained by fetching: naming it and, optionally, supplying a property query string that constrains which implementation is acceptable.

EVP_MD *md = EVP_MD_fetch(libctx, "SHA2-256", "provider=default");
EVP_CIPHER *c = EVP_CIPHER_fetch(libctx, "AES-256-GCM", NULL);

A property query is a sequence of key/value assertions. Properties may be required or merely preferred, and a query may specify that a property must be absent. The resulting selection is a filter over every algorithm advertised by every activated provider in the given library context.

Fetched objects are reference-counted and owned by the caller, which must free them. This is a genuine change from the 1.1.1 idiom, where EVP_sha256() returned a pointer to a static structure that the caller did not own.

Fetching also has a cost, and the OpenSSL project states it plainly: retrieving an algorithm from a provider "involves searching for an algorithm by name", which "is much slower than directly accessing a method table", with the recommendation to prefetch algorithms used many times. Chapter 18 treats the implications for a TLS workload, where a handshake fetches several algorithms and a bulk transfer uses one of them continuously.

3.6 Library contexts

An OSSL_LIB_CTX is a scope within which providers are loaded and configuration applies. Code that passes NULL uses the default context. Two library contexts in one process may have entirely different providers activated and therefore resolve the same algorithm name to different implementations.

For a TLS design this is both a tool and a hazard. It is a tool because an application can confine an experimental provider to a context used by one connection class, leaving the rest of the process on stock implementations. It is a hazard because an SSL_CTX is associated with a library context at creation, and an algorithm that is available in the application's default context but not in the one the SSL_CTX uses will fail to resolve at a point in the handshake that produces an unhelpful error. Chapter 13 treats configuration and activation with this in mind.

3.7 Activation and configuration

Providers reach a process by one of two routes. The application may load one programmatically with OSSL_PROVIDER_load(), naming it and the context. Alternatively the configuration file may activate it, which requires no application change at all:

openssl_conf = openssl_init

[openssl_init]
providers = provider_sect

[provider_sect]
default = default_sect
tlsext  = tlsext_sect

[default_sect]
activate = 1

[tlsext_sect]
module   = /usr/local/lib/ossl-modules/tlsext.so
activate = 1

The configuration route is what makes a provider deployable to an application that cannot be modified — the ordinary case for introducing an algorithm into an existing TLS service. It is also the route that most needs care, because activating a provider changes algorithm resolution for everything in that context, including code paths the deployer was not thinking about.

A trap worth stating early. Activating a non-default provider does not by itself deactivate the default provider, but a configuration that lists only the new provider does. A configuration file that activates a custom provider and omits default will leave the process without the algorithms everything else expects, and the resulting failures appear far from the configuration that caused them.

3.8 What the architecture does not provide

Three limits are worth stating now, because the design chapters must work within them.

A provider supplies implementations of algorithms. It does not supply protocol behaviour, message formats or state machines. Nothing in the provider interface allows a module to change how a handshake is sequenced.

A provider is selected by name and property, so a consumer must already be asking for something the provider can answer to. An algorithm nobody fetches is never used, however correctly it is implemented.

Finally, the provider boundary is a trust boundary in one direction only. The core calls into provider code with the process's full privileges; a provider is not sandboxed. Chapter 17 develops the consequences.