Path: blob/main/crypto/openssl/doc/designs/quic-design/record-layer.md
34877 views
Design Problem: Abstract Record Layer
This document covers the design of an abstract record layer for use in (D)TLS. The QUIC record layer is handled separately.
A record within this document refers to a packet of data. It will typically contain some header data and some payload data, and will often be cryptographically protected. A record may or may not have a one-to-one correspondence with network packets, depending on the implementation details of an individual record layer.
The term record comes directly from the TLS and DTLS specifications.
Libssl supports a number of different types of record layer, and record layer variants:
Standard TLS record layer
Standard DTLS record layer
Kernel TLS record layer
Within the TLS record layer there are options to handle "multiblock" and "pipelining" which are different approaches for supporting the reading or writing of multiple records at the same time. All record layer variants also have to be able to handle different protocol versions.
These different record layer implementations, variants and protocol versions have each been added at different times and over many years. The result is that each took slightly different approaches for achieving the goals that were appropriate at the time and the integration points where they were added were spread throughout the code.
The introduction of QUIC support will see the implementation of a new record layer, i.e. the QUIC-TLS record layer. This refers to the "inner" TLS implementation used by QUIC. Records here will be in the form of QUIC CRYPTO frames.
Requirements
The technical requirements document lists these requirements that are relevant to the record layer:
The current libssl record layer includes support for TLS, DTLS and KTLS. QUIC will introduce another variant and there may be more over time. The OMC requires a pluggable record layer interface to be implemented to enable this to be less intrusive, more maintainable, and to harmonize the existing record layer interactions between TLS, DTLS, KTLS and the planned QUIC protocols. The pluggable record layer interface will be internal only for MVP and be public in a future release.
The minimum viable product (MVP) for the next release is a pluggable record layer interface and a single stream QUIC client in the form of s_client that does not require significant API changes. In the MVP, interoperability should be prioritized over strict standards compliance.
Once we have a fully functional QUIC implementation (in a subsequent release), it should be possible for external libraries to be able to use the pluggable record layer interface and it should offer a stable ABI (via a provider).
The MVP requirements are:
a pluggable record layer (not public for MVP)
Candidate Solutions that were considered
This section outlines two different solution approaches that were considered for the abstract record layer
Use a METHOD based approach
A METHOD based approach is simply a structure containing function pointers. It is a common pattern in the OpenSSL codebase. Different strategies for implementing a METHOD can be employed, but these differences are hidden from the caller of the METHOD.
In this solution we would seek to implement a different METHOD for each of the types of record layer that we support, i.e. there would be one for the standard TLS record layer, one for the standard DTLS record layer, one for kernel TLS and one for QUIC-TLS.
In the MVP the METHOD approach would be private. However, once it has stabilised, it would be straight forward to supply public functions to enable end user applications to construct their own METHODs.
This option is simpler to implement than the alternative of having a provider based approach. However it could be used as a "stepping stone" for that, i.e. the MVP could implement a METHOD based approach, and subsequent releases could convert the METHODs into fully fetchable algorithms.
Pros:
Simple approach that has been used historically in OpenSSL
Could be used as the basis for the final public solution
Could also be used as the basis for a fetchable solution in a subsequent release
If this option is later converted to a fetchable solution then much of the effort involved in making the record layer fetchable can be deferred to a later release
Cons:
Not consistent with the provider based approach we used for extensibility in 3.0
If this option is implemented and later converted to a fetchable solution then some rework might be required
Use a provider based approach
This approach is very similar to the alternative METHOD based approach. The main difference is that the record layer implementations would be held in providers and "fetched" in much the same way that cryptographic algorithms are fetched in OpenSSL 3.0.
This approach is more consistent with the approach adopted for extensibility in 3.0. METHODS are being deprecated with providers being used extensively.
Complex objects (e.g. an SSL
object) cannot be passed across the libssl/provider boundary. This imposes some restrictions on the design of the functions that can be implemented. Additionally implementing the infrastructure for a new fetchable operation is more involved than a METHOD based approach.
Pros:
Consistent with the extensibility solution used in 3.0
If this option is implemented immediately in the MVP then it would avoid later rework if adopted in a subsequent release
Cons:
More complicated to implement than the simple METHOD based approach
Cannot pass complex objects across the provider boundary
Selected solution
The METHOD based approach has been selected for MVP, with the expectation that subsequent releases will convert it to a full provider based solution accessible to third party applications.
Solution Description: The METHOD based approach
This section focuses on the selected approach of using METHODs and further elaborates on how the design works.
A proposed internal record method API is given in Appendix A.
An OSSL_RECORD_METHOD
represents the implementation of a particular type of record layer. It contains a set of function pointers to represent the various actions that can be performed by a record layer.
An OSSL_RECORD_LAYER
object represents a specific instantiation of a particular OSSL_RECORD_METHOD
. It contains the state used by that OSSL_RECORD_METHOD
for a specific connection (i.e. SSL
object). Any SSL
object will have at least 2 OSSL_RECORD_LAYER
objects associated with it - one for reading and one for writing. In some cases there may be more than 2 - for example in DTLS it may be necessary to retransmit records from a previous epoch. There will be different OSSL_RECORD_LAYER
objects for different protection levels or epochs. It may be that different OSSL_RECORD_METHOD
s are used for different protection levels. For example a connection might start using the standard TLS record layer during the handshake, and later transition to using the kernel TLS record layer once the handshake is complete.
A new OSSL_RECORD_LAYER
is created by calling the new
function of the associated OSSL_RECORD_METHOD
, and freed by calling the free
function. The parameters to the new
function also supply all of the cryptographic state (e.g. keys, ivs, symmetric encryption algorithms, hash algorithm etc) used by the record layer. The internal structure details of an OSSL_RECORD_LAYER
are entirely hidden to the rest of libssl and can be specific to the given OSSL_RECORD_METHOD
. In practice the standard internal TLS, DTLS and KTLS OSSL_RECORD_METHOD
s all use a common OSSL_RECORD_LAYER
structure. However the QUIC-TLS implementation is likely to use a different structure layout.
All of the header and payload data for a single record will be represented by an OSSL_RECORD_TEMPLATE
structure when writing. Libssl will construct a set of templates for records to be written out and pass them to the "write" record layer. In most cases only a single record is ever written out at one time, however there are some cases (such as when using the "pipelining" or "multibuffer" optimisations) that multiple records can be written in one go.
It is the record layer's responsibility to know whether it can support multiple records in one go or not. It is libssl's responsibility to split the payload data into OSSL_RECORD_TEMPLATE
objects. Libssl will call the record layer's get_max_records()
function to determine how many records a given payload should be split into. If that value is more than one, then libssl will construct (up to) that number of OSSL_RECORD_TEMPLATE
s and pass the whole set to the record layer's write_records()
function.
The implementation of the write_records
function must construct the appropriate number of records, apply protection to them as required and then write them out to the underlying transport layer BIO. In the event that not all the data can be transmitted at the current time (e.g. because the underlying transport has indicated a retry), then the write_records
function will return a "retry" response. It is permissible for the data to be partially sent, but this is still considered a "retry" until all of the data is sent.
On a success or retry response libssl may free its buffers immediately. The OSSL_RECORD_LAYER
object will have to buffer any untransmitted data until it is eventually sent.
If a "retry" occurs, then libssl will subsequently call retry_write_records
and continue to do so until a success return value is received. Libssl will never call write_records
a second time until a previous call to write_records
or retry_write_records
has indicated success.
Libssl will read records by calling the read_record
function. The OSSL_RECORD_LAYER
may read multiple records in one go and buffer them, but the read_record
function only ever returns one record at a time. The OSSL_RECORD_LAYER
object owns the buffers for the record that has been read and supplies a pointer into that buffer back to libssl for the payload data, as well as other information about the record such as its length and the type of data contained in it. Each record has an associated opaque handle rechandle
. The record data must remain buffered by the OSSL_RECORD_LAYER
until it has been released via a call to release_record()
.
A record layer implementation supplies various functions to enable libssl to query the current state. In particular:
unprocessed_read_pending()
: to query whether there is data buffered that has already been read from the underlying BIO, but not yet processed.
processed_read_pending()
: to query whether there is data buffered that has been read from the underlying BIO and has been processed. The data is not necessarily application data.
app_data_pending()
: to query the amount of processed application data that is buffered and available for immediate read.
get_alert_code()
: to query the alert code that should be used in the event that a previous attempt to read or write records failed.
get_state()
: to obtain a printable string to describe the current state of the record layer.
get_compression()
: to obtain information about the compression method currently being used by the record layer.
get_max_record_overhead()
: to obtain the maximum amount of bytes the record layer will add to the payload bytes before transmission. This does not include any expansion that might occur during compression. Currently this is only implemented for DTLS.
In addition, libssl will tell the record layer about various events that might occur that are relevant to the record layer's operation:
set1_bio()
: called if the underlying BIO being used by the record layer has been changed.
set_protocol_version()
: called during protocol version negotiation when a specific protocol version has been selected.
set_plain_alerts()
: to indicate that receiving unencrypted alerts is allowed in the current context, even if normally we would expect to receive encrypted data. This is only relevant for TLSv1.3.
set_first_handshake()
: called at the beginning and end of the first handshake for any given (D)TLS connection.
set_max_pipelines()
: called to configure the maximum number of pipelines of data that the record layer should process in one go. By default this is 1.
set_in_init()
: called by libssl to tell the record layer whether we are currently in_init
or not. Defaults to "true".
set_options()
: called by libssl in the event that the current set of options to use has been updated.
set_max_frag_len()
: called by libssl to set the maximum allowed fragment length that is in force at the moment. This might be the result of user configuration, or it may be negotiated during the handshake.
increment_sequence_ctr()
: force the record layer to increment its sequence counter. In most cases the record layer will entirely manage its own sequence counters. However in the DTLSv1_listen() corner case, libssl needs to initialise the record layer with an incremented sequence counter.
alloc_buffers()
: called by libssl to request that the record layer allocate its buffers. This is a hint only and the record layer is expected to manage its own buffer allocation and freeing.
free_buffers()
: called by libssl to request that the record layer free its buffers. This is a hint only and the record layer is expected to manage its own buffer allocation and freeing.
Appendix A: The internal record method API
The internal recordmethod.h header file for the record method API: