I've had the same doubt, and through some research I've come to the following conclusion: if you use TLS on top of a reliable protocol (e.g. TCP) then reassembly is simply the result of the concatenation of the received Record Protocol fragments. Since such a reliable protocol guarantees that the data is received in the same order it was sent (and is, of course, not corrupt), the simple concatenation of the received bytes is sufficient for reassembly, and it's up to the client to know how to interpret those received bytes. However, what happens when TLS is not run over a reliable protocol like TCP? There is a protocol called Datagram Transport Layer Security that does that. Quoting directly from Wikipedia:
because it uses UDP or SCTP, the application has to deal with packet reordering, loss of datagram and data larger than the size of a datagram network packet
How exactly does it work in this case? I've found this draft from IETF which outlining the workings of DTLS. The issue for messages containing application data is solved with the following constraint:
Each DTLS message MUST fit within a single transport layer datagram
Handshake messages, however, are a different matter:
However, handshake messages are potentially bigger than the maximum record size. Therefore, DTLS provides a mechanism for fragmenting a handshake message over a number of records, each of which can be transmitted separately, thus avoiding IP fragmentation
In particular, the format of the Handshake Message is the following:
struct {
HandshakeType msg_type; /* handshake type */
uint24 length; /* bytes in message */
uint16 message_seq; /* DTLS-required field */
uint24 fragment_offset; /* DTLS-required field */
uint24 fragment_length; /* DTLS-required field */
select (HandshakeType) {
case client_hello: ClientHello;
case server_hello: ServerHello;
case end_of_early_data: EndOfEarlyData;
case hello_retry_request: HelloRetryRequest;
case encrypted_extensions: EncryptedExtensions;
case certificate_request: CertificateRequest;
case certificate: Certificate;
case certificate_verify: CertificateVerify;
case finished: Finished;
case new_session_ticket: NewSessionTicket;
case key_update: KeyUpdate; /* reserved */
} body;
} Handshake;
As you can see, it includes a sequence number, a fragment offset and a fragment length. Those are the fields that I also expected to see in the header of messages of the Record Protocol of TLS, but they turn out to not be necessary when using TCP.
I hope you may find this useful.