Skip to content

Working with Encrypted TLS Records

Overview

After generating symmetric keys used to protect later handshake messages or application traffic, you are ready begin using these keys to encrypt outbound traffic or decrypt incoming traffic. The helper classes provided for this project encapsulates this functionality into the TLSCipher class, which you can retrieve by calling get_handshake_cipher() (after receiving the server's hello message) and get_application_cipher() prior to sending the client's finish message.

AEAD Ciphers

All TLS 1.3 cipher suites implement a mode of encryption known as Authenticated Encryption with Associated Data (AEAD). While symmetric encryption is used to generate ciphertext that can't be read without a key, the ciphertext (and underlying plaintext) produced by many encryption modes can often be modified without detection. Authenticated encryption modes add an authentication tag to the ciphertext in order to ensure the integrity of the message by detecting changes that occur after the data is encrypted. AEAD takes this integrity mechanism one step further, providing integrity across plaintext data that is associated with an encrypted message. This feature can be used to ensure that metadata outside of the encrypted messages is left intact.

When TLS encrypts a message, the 5-byte record header is left unencrypted. By using AEAD algorithms, TLS 1.3 defends against changes across the entire TLS record construct while leaving the record header visible to the recipient. Even a change to the unencrypted record header, e.g., changing the TLS record type or version, can be detected by the recipient.

TLSCipher Operations

The get_handshake_cipher() and get_application_cipher() methods return TLSCipher objects that provide their own encrypt() and decrypt() methods. Since TLSCipher objects implement AEAD ciphers, two inputs are required for each cryptographic operation.

  1. The plaintext (for encrypt) or ciphertext and authentication tag (for decrypt)
  2. Additional data that will be bound to the cipher text through the AEAD integrity mechanism

The TLSCipher encrypt() method returns a new byte object that includes the ciphertext and authentication tag. The length of this object equals the length of the original plaintext plus 16 bytes for the authentication tag.

The byte object returned by decrypt() includes only the plaintext byte that were originally encrypted without any additional data or authentication tag. The 16-byte authentication tag of the ciphertext is stripped off and validated by the decrypt operation. An exception will be thrown if the authentication tag cannot be verified due to changes in the ciphertext or additional data.

Encrypted Record Structure

You've already worked with unencrypted records used to send client and server hello messages. To complete the handshake and start exchanging application data, you also need to be able to build and parse encrypted records. On the outside, these records look similar to those you've already seen, though they are always assigned record type 23 (the assigned value for application data) to ensure that the connection peer can recognize that the record data will need to be decrypted before further processing.

The following steps are needed to build a protected record:

  1. Append the real message type (such as 22 or 23) as a 1-byte trailer at the end of the message body to obtain the plaintext record data
  2. Compute length of the encrypted record body by adding 16 bytes to the plaintext record length to account for the authentication tag
  3. Compute the 5 byte record header with type 23 (application data), version b'\x03\03', and the computed ciphertext length
  4. Using the 5-byte record header as additional authenticated data, encrypt the body of the record
  5. Concatenate the header and encrypted body

Likewise, the process to decrypt and parse a message looks like so:

  1. Parse the 5 byte record header to obtain the type, version, and length of the record
  2. If the record type is 23, you will need to decrypt in order to proceed
  3. Split the record into its header and the remaining body
  4. Passing in the 5-byte record header as additional authenticated data, decrypt the body of the record
  5. Split the plaintext returned from the decryption to obtain the message body and 1-byte record type (the real type this time)

Example Encrypt/Decrypt

The following code demonstrates each part of this process.

handshake_cipher = get_handshake_cipher()

# Parse
rtype, rversion, rlength = buffer[0], buffer[1:3], int.from_bytes(buffer[3:5], 'big')
if rtype = 23: # must be encrypted
    record_header, ciphertext = buffer[0:5], buffer[5:5+rlength]
    plaintext = handshake_cipher.decrypt(ciphertext, record_header)
    data, rtype = plaintext[0:-1], plaintext[-1]

# rtype is now set to the actual record type taken from the final byte of the decrypted message
# data is now set to the actual plaintext message and can be parsed as you've done with server hello

# Create an encrypted record containing data you want to send
TAG_LENGTH = 16
data = b'insert real data here'
plaintext = data + b'\x16' # This should be the actual message type. \x16 is a handshake. \x17 is application data
ciphertext_length = len(plaintext) + TAG_LENGTH
record_header = b'\x17' + b'\x03\x03' + ciphertext_length.to_bytes(2, 'big')
ciphertext = handshake_cipher.encrypt(plaintext, record_header)
record = record_header + ciphertext
Back to top