Why does every protected TLS 1.3 record show application_data(23) in its header, and where is the real content type?
answer
- the outer type is deliberately uninformative
- the real type rides inside the ciphertext
- padding is trailing zero octets
- scan backwards past the zeros
- last non-zero byte is the type
basics
~20 sTLS 1.3 moved the real content type inside the encrypted payload. The outer header's opaque_type reads application_data(23) for every protected record; the receiver decrypts, strips trailing zero octets, and reads the last non-zero byte as the type.
solid answer
~40 sA protected record's payload is a `TLSInnerPlaintext`: the content, then one byte naming its real type, then any number of zero octets as padding. The outer `TLSCiphertext` header carries `opaque_type`, a frozen `legacy_record_version` and the length, and `opaque_type` is always `application_data(23)` regardless of what the record holds. A receiver decrypts, scans backwards past the zeros, and takes the first non-zero octet as the content type - `alert(21)`, `handshake(22)` or `application_data(23)`. If the whole payload is zeros there is no type byte and the connection ends with a fatal alert. The effect is that an on-path observer cannot tell an alert or a post-handshake message from application data, and the padding lets an endpoint blur how large its real payloads are.
code
pseudocode · 8 linesfunction split_inner_plaintext(decrypted):
index = length(decrypted) - 1
for each position from index down to 0:
if decrypted[position] is not zero:
content_type = decrypted[position]
content = decrypted[0 .. position - 1]
return content_type, content
reject("no content type found: end the connection")go deeper
Remember that after the keys are in force every TLS 1.3 record looks like application data on the wire, and the real type is inside the encrypted part.
Explain the TLSInnerPlaintext layout - content, then the type octet, then zero padding - and how a receiver recovers the type by scanning backwards past the zeros.
State the limit honestly: the type is hidden, but record sizes, counts, timing and direction are not, and padding buys size blurring at a bandwidth cost.
The judgment worth voicing is how much padding is worth buying: uniform record sizes cost real bandwidth on a continuous feed and address only one of several observable signals.
## Three structures, not one The record layer works with three named structures, and keeping them straight is most of this question: - **`TLSPlaintext`** - an unprotected record: content type, `legacy_record_version`, length, fragment. - **`TLSInnerPlaintext`** - what actually gets encrypted once keys are in force: the content, then a single octet giving the **real** content type, then zero or more zero octets of padding. - **`TLSCiphertext`** - the protected record as it appears on the wire: `opaque_type`, `legacy_record_version`, length, encrypted payload. The type byte therefore appears twice in the design and means two different things. The outer `opaque_type` is a placeholder; the inner one is the truth. ## Reading the inner type Because the padding is trailing zeros and the type byte sits immediately before it, the receiver works backwards: 1. Decrypt the record, obtaining the `TLSInnerPlaintext`. 2. Scan from the last octet towards the front, skipping every zero. 3. The first non-zero octet found is the content type; everything before it is the content. 4. If no non-zero octet exists at all, the record has no content type and the receiver ends the connection with a fatal alert. That last step is a real case, not a formality: a record consisting entirely of padding is malformed, and treating it as an empty application data record instead would let a peer feed the layer records it cannot classify. ## The content types | type | what it carries | notes in TLS 1.3 | |---|---|---| | `invalid(0)` | nothing legitimate | never sent; receiving it is an error | | `change_cipher_spec(20)` | a single dummy octet | carries no meaning; sent only so equipment expecting the older record shape sees something familiar, and dropped by the receiver in the window where it is permitted | | `alert(21)` | exactly one alert | one alert per record, never fragmented | | `handshake(22)` | handshake messages | including post-handshake ones such as a key update or a new session ticket | | `application_data(23)` | the caller's bytes | also the value the outer `opaque_type` always shows | ## What the move actually hides Before TLS 1.3 the content type sat in the clear in every record header, so anyone on the path could count the alerts, spot post-handshake messages, and see the boundary between handshake and application traffic. Moving it inside removes that signal: after the keys are in force, every record announces itself as application data. What remains visible is worth stating precisely, because overstating the benefit is the classic error here: - the **length** of each record is in the header, so payload sizes are observable; - the **timing** and **direction** of each record are observable; - the **number** of records is observable. Padding blunts the first of those. An endpoint may append zero octets to make records a uniform size, or to disguise a short control record as an ordinary data record, at the cost of the bandwidth those octets consume. It does nothing about timing or direction, and record sizes are still bounded by the same ceilings - the padding counts against the plaintext limit. ## Why a live feed makes this concrete On a continuous subtitle stream, an observer who could read content types would see rotation messages, alerts and the end of the feed as distinct events. With the type inside, all it sees is a sequence of small protected records of varying length, then no more records. Even the ending is no longer self-announcing on the wire: whether the stream was closed deliberately or cut is something only the endpoints can distinguish, from the protected `close_notify` one of them did or did not send. ## The traps - Reading the **first** byte of the decrypted payload as the type. The type is at the end, before the padding. - Assuming `change_cipher_spec(20)` still changes anything. In TLS 1.3 it is inert, unprotected, and dropped. - Claiming the record layer hides what it does not: sizes, counts and timing all survive. - Forgetting that the padding is part of the plaintext and counts towards the fragment ceiling.
- What is a change_cipher_spec(20) record doing in a TLS 1.3 exchange at all?Nothing protocol-relevant. It is an unprotected record carrying a single dummy octet, sent so that network equipment expecting the older record shape sees a familiar sequence. A TLS 1.3 receiver drops it where it is permitted rather than acting on it, and it never changes any key.
- If every protected record looks alike, what can an on-path observer still learn?The size of each record, its direction, its timing and how many there are - enough to infer the shape of a conversation even without its content. Padding blunts size analysis at the cost of bandwidth; it leaves timing, direction and record counts exactly as they were.
- Why is the padding made of zeros at the end rather than a length field at the front?Trailing zeros let the receiver find the boundary by scanning backwards, with no length field that an attacker could manipulate to point elsewhere in the payload. The type byte is the first non-zero octet from the end, so the structure is unambiguous without carrying any extra metadata.
saying these in an interview costs you the question
- Thinks the content type is still visible in the record header
- Claims padding makes record sizes invisible to an observer
- Reads the first byte of the payload as the content type
- Believes change_cipher_spec still changes keys in TLS 1.3
- Treats an all-zero decrypted payload as an empty record