Networking for beginners. Part 23: TLS and certificates

All parts of the series “Networking for beginners”

For twenty-two parts in a row we have mentioned "TLS" in a single phrase: "encryption on top of TCP that HTTPS runs on". It's time to take it apart as a protocol. IPsec protected packets at the network layer, and applications knew nothing about it; TLS, Transport Layer Security, does the opposite: it lives inside a single TCP connection (part ten), and the application decides for itself to use it. In return it needs no gateways, no tunnels, no administrator rights: a library in the browser and a library on the server, and that's it. That is why TLS protects the overwhelming share of internet traffic: HTTPS, mail between servers, DNS in DoT and DoH from part twelve, OpenVPN from part twenty.

Half of this part is not about encryption but about trust: how the browser knows it is talking to www.example.com and not to whoever has stepped into the middle. Encryption is useless without that, and the answer to this question is certificates.


What TLS promises

Three things for one connection between a client and a server:

  • confidentiality — nobody between them can read the data;
  • integrity — nobody can change it unnoticed;
  • server authenticity — the client makes sure the server is who it claims to be; optionally, the server verifies the client as well.

The version history is short: SSL 2.0 and 3.0 (1995–1996, Netscape) are broken and forbidden; TLS 1.0 and 1.1 were retired in 2021; TLS 1.2 (2008) is still everywhere; TLS 1.3 (2018, RFC 8446) is the default in modern browsers and servers, and we will take it as the main one, noting what differs in 1.2.


Records

TLS splits the TCP stream into records — its own framing layer with a 5-byte header:

 bytes:   1       2         2        ≤ 16 384
        ┌──────┬────────┬────────┬────────────────────────┐
        │ Type │ Version│ Length │ content                │
        └──────┴────────┴────────┴────────────────────────┘

Four types: 22 handshake — handshake messages, 23 application data — the application's data, 21 alert — errors and closing, 20 change_cipher_spec — a leftover from 1.2, kept in 1.3 for compatibility. The Version in 1.3 is always 0x0303 (that is, "1.2") — for the sake of the same old middleboxes that broke on unfamiliar versions; the real version is negotiated inside extensions.

This is the very stream framing mentioned in the part on TCP: the application gets a stream of bytes, and the TLS library knows from the record headers where one ends and the next begins. After the handshake each record is encrypted and authenticated separately, with its own sequence number, so a record cannot be reordered or replayed.


The TLS 1.3 handshake

One round trip to the server and back — and data starts flowing:

   client                                                             server
     │                                                                   │
     ├── ClientHello ───────────────────────────────────────────────────►│
     │   versions, random number, cipher suites,                         │
     │   key_share: my public DH key (x25519),                           │
     │   server_name: www.example.com, ALPN: h2, http/1.1                │
     │                                                                   │
     │◄── ServerHello ───────────────────────────────────────────────────┤  in the clear
     │   chosen cipher, random number, key_share: server's DH key        │
     │                                                                   │
     │   ═══ both sides have the shared secret, encrypted from here ═══  │
     │                                                                   │
     │◄── EncryptedExtensions ───────────────────────────────────────────┤  ALPN: h2
     │◄── Certificate ───────────────────────────────────────────────────┤  certificate chain
     │◄── CertificateVerify ─────────────────────────────────────────────┤  signature with the private key
     │◄── Finished ──────────────────────────────────────────────────────┤  HMAC over the whole handshake
     │                                                                   │
     │  verify the chain, the name, the signature, Finished              │
     ├── Finished ──────────────────────────────────────────────────────►│
     ├── application data (GET / HTTP/2) ───────────────────────────────►│
     │◄── application data ──────────────────────────────────────────────┤

ClientHello is the only thing in TLS 1.3 that is visible in the clear in full, and there is plenty to look at in it:

  • supported versions in the supported_versions extension (the Version field holds a fake 1.2);
  • cipher suites: in 1.3 there are only five, and the name speaks only of the symmetric cipher and the hash: TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256. Key exchange and signatures are negotiated separately in 1.3;
  • supported_groups — which Diffie-Hellman groups the client supports: x25519, secp256r1, and key_share — a ready-made public DH key for the most likely group right away. This is what saves a round trip: in 1.2 the client first learned what the server supports and only then sent its key;
  • server_name (SNI) — the name of the server the client is going to. It is needed because hundreds of sites live on one IP address, and the server has to choose which certificate to present before anything is encrypted. The consequence: the site name is visible to everyone along the way;
  • ALPN — which application protocol the client wants inside: h2 (HTTP/2) or http/1.1. Negotiating this inside TLS is cheaper than a separate exchange;
  • signature_algorithms — which signatures the client can use to verify certificates and CertificateVerify.

ServerHello is the choice from what was offered and the server's own public DH key. From this moment both sides have a shared secret, and the rest of the handshake is encrypted: in 1.3 the server certificate does not travel over the network in the clear, unlike in 1.2.

Certificate — the server certificate and the intermediate certificates up to the root (more on them below). CertificateVerify — a signature with the private key matching the certificate, over the hash of the entire handshake up to this point. This is the key moment of authentication: anyone can copy the certificate, it is public; but only the owner of the private key can sign this particular handshake. Finished — an HMAC over the whole handshake with a key from the shared secret: proof that both sides saw the same thing and nobody substituted anything along the way.

The client checks three things: the certificate chain up to a trusted root, that the name in the certificate matches where it was going, and the signature in CertificateVerify. If everything checks out, it sends its Finished and the data.

Where the keys come from and why forward secrecy

The shared secret is the result of ephemeral Diffie-Hellman: each side generated a one-time key pair, exchanged the public halves, and from its own private half and the other's public half obtained the same number, which never crossed the network. Then the HKDF function (key derivation based on HMAC) derives the handshake keys from this number and the handshake hash, and then the data keys for each direction.

The certificate's private key encrypts nothing in this process. It only signs. So if it is stolen a year from now, traffic recorded today cannot be decrypted: the session keys were one-time and destroyed long ago. This is forward secrecy, and in 1.3 it is mandatory. 1.2 still allowed "RSA key exchange", where the client encrypted the secret with the server's public key from the certificate, and there stealing the key exposed everything recorded retroactively; this mode was removed from 1.3.

What's different in TLS 1.2

  • Two round trips instead of one: ClientHello → ServerHello, Certificate, ServerKeyExchange (a signed DH key), ServerHelloDone → ClientKeyExchange, ChangeCipherSpec, Finished → ChangeCipherSpec, Finished.
  • The certificate travels in plain text.
  • Cipher suites with full names: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 reads as "ECDHE key exchange, RSA signature, AES-128-GCM cipher, SHA-256 hash". Such names also show whether there is forward secrecy: ECDHE or DHE — yes, plain RSA — no.
  • More old algorithms: CBC modes with a separate HMAC, RC4, 3DES — everything that was cleaned out of 1.3.

Resumption and 0-RTT

After the handshake the server can send a session ticket — a snapshot of the state encrypted with its own key. On the next connection the client presents it as a PSK, and the handshake does without certificates and, optionally, without waiting for a reply: application data goes right in the first packet (0-RTT). The price of 0-RTT is that such data can be replayed from a recording: there is nothing to protect against replay. So only replay-safe requests (GET) are allowed in 0-RTT, and many servers simply don't enable it.


The certificate

A certificate is a public key, the name of its owner, and the signature of whoever vouches for this, in X.509 format. You can look at any of them:

$ openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null | openssl x509 -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 04:a1:b2:c3:...
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: C = US, O = Let's Encrypt, CN = E6
        Validity
            Not Before: Sep 15 10:00:00 2026 GMT
            Not After : Dec 14 10:00:00 2026 GMT
        Subject: CN = www.example.com
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
        X509v3 extensions:
            X509v3 Subject Alternative Name:
                DNS:www.example.com, DNS:example.com
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Basic Constraints: critical
                CA:FALSE
            Authority Information Access:
                OCSP - URI:http://e6.o.lencr.org
                CA Issuers - URI:http://e6.i.lencr.org/
            CT Precertificate SCTs: ...

Field by field:

  • Subject — whom it was issued to. Historically, the CN field; today names are taken only from the Subject Alternative Name: a list of DNS names (and IP addresses) for which the certificate is valid. *.example.com is a wildcard covering one level: a.example.com, but not a.b.example.com and not example.com itself.
  • Issuer — who signed it. Here it is a Let's Encrypt intermediate certificate named E6.
  • Validity — the validity period. 90 days for Let's Encrypt; for commercial ones up to 398 days now, and browsers are cutting the maximum to 47 days by 2029: a short lifetime limits the damage from a key leak and forces renewal to be automated.
  • Public Key — the server's public key, the counterpart of the private key that signed CertificateVerify.
  • Key Usage, Extended Key Usage — what the key is for: signing handshakes, for a TLS server. A certificate with CA:TRUE in Basic Constraints is a certificate authority's certificate and can be used to sign others.
  • Authority Information Access — where to get the issuer's certificate and where to ask about revocation.
  • Signature — the signature over all the content with the issuer's private key.

The chain of trust

The client cannot verify the certificate on its own: the signature was made with someone else's key. It verifies the chain:

 ISRG Root X1  ───signed───►  E6 (intermediate)   ───signed───►  www.example.com
 (in the OS and browser       (sent by the server                (sent by the server
  trusted root store,          in Certificate)                    in Certificate)
  self-signed)
  1. The signature of the www.example.com certificate is verified with the public key from the E6 certificate.
  2. The signature of E6 — with the public key of ISRG Root X1.
  3. ISRG Root X1 is not verified against anything: it sits in the trusted root store of the operating system or browser. This is the foundation of trust: the OS vendor decided that this authority can be trusted and put its root into the system. The store holds about a hundred and fifty roots, and behind each one is an audited organization.

Why not sign server certificates directly with the root: the root's private key is the most valuable thing a certificate authority has; it is kept offline in a hardware module and used once every few years to sign an intermediate. The intermediate signs millions of certificates every day; if it is compromised, it is revoked and a new one is issued, while the root in the stores of every device in the world stays the same.

Hence the first rule of server configuration: the server must send the intermediate certificates. The client knows only the roots; if Certificate contains just the server certificate, the chain to the root cannot be built, and the browser will say "unknown issuer" even though the certificate is genuine. Half of the "works for me but not for the customer" errors are a missing intermediate: the administrator's browser has it cached, the customer's doesn't.

Who issues them

A certificate authority (CA) is an organization whose roots are in the stores and whose job is to check that the certificate is requested by the actual owner of the name, and to sign it. Issuance looks like this:

  1. The server owner generates a key pair locally. The private key goes nowhere.
  2. They create a CSR (certificate signing request): the public key, the desired names, and a signature with their own private key — proof that they hold the key.
  3. The CA verifies ownership of the name and signs the CSR with its intermediate key. Done.

Validation comes in three levels, and the level affects the price, not the cryptography:

  • DV (domain validation) — the CA checks only that the requester controls the domain: can put a file on the site or a record in DNS. Fully automatable.
  • OV — plus verification of the organization by documents; its name appears in the certificate.
  • EV — extended validation; browsers used to show a green bar with the company name, stopped in 2019, and EV has little point left.

The padlock in the browser means exactly one thing: a connection to whoever controls this domain. Not "the site is honest", not "the company is real".

Let's Encrypt and ACME

Before 2015 a certificate cost money and was issued by hand: fill in a form, get an email, copy a file to the server, repeat a year later. Let's Encrypt is a non-profit CA that issues DV certificates for free and automatically via the ACME protocol (RFC 8555), and in ten years it has become the largest CA in the world: more than half of the websites on the internet use its certificates.

ACME is an HTTPS API through which a program on the server (certbot, acme.sh, the built-in clients in Caddy and Traefik, cert-manager in Kubernetes) talks to the CA:

   ACME client on the server                                    Let's Encrypt
     │                                                                │
     ├── registration: account key ──────────────────────────────────►│
     ├── order: I want a certificate for www.example.com ────────────►│
     │◄── here are the challenges: http-01, dns-01, tls-alpn-01 ──────┤
     │   and a token for each                                         │
     │                                                                │
     │  http-01: put the token at                                     │
     │  http://www.example.com/.well-known/acme-challenge/<token>     │
     │  dns-01:  TXT record _acme-challenge.www.example.com = hash    │
     │                                                                │
     ├── done, go check ─────────────────────────────────────────────►│
     │                           CA fetches the token from several    │
     │                           points on the internet, compares     │
     │◄── challenge passed ───────────────────────────────────────────┤
     ├── CSR ────────────────────────────────────────────────────────►│
     │◄── certificate + chain ────────────────────────────────────────┤

The two main challenges:

  • HTTP-01: the CA must fetch the token over HTTP on port 80 from the public address the name resolves to. So the server must be reachable from the internet, and a wildcard cannot be issued this way: you cannot verify "all names at once".
  • DNS-01: the token goes into a TXT record _acme-challenge.<name> (part twelve). The server does not need to be reachable from outside, and you can get wildcards and certificates for internal hosts; all you need is the DNS provider's API so the client can create the record itself.

The certificate lasts 90 days; the client renews it on a timer after 60, repeating the same procedure, with no human involved. If the automation breaks, you find out from a Let's Encrypt email 20 days before expiry rather than from the site going down. To prevent abuse there are rate limits: 50 certificates per domain per week, five duplicates, and they are also the reason people test against the Let's Encrypt staging environment rather than production.

The Let's Encrypt chain: server certificate ← intermediate (E5/E6 for ECDSA, R10/R11 for RSA) ← the root ISRG Root X1, which has been in the stores since 2016–2018. The old cross-signed path via the DST Root CA X3 root ended in 2024, and devices that lack ISRG Root X1 (Android before 7.1 without updates) no longer trust Let's Encrypt.

One command that does everything listed above:

$ sudo certbot certonly --webroot -w /var/www/html -d www.example.com -d example.com

Your own certificate authority

For an internal network a public CA is not needed and not always possible: names like pg.internal don't exist in public DNS. Then you set up your own CA (OpenSSL, step-ca, Active Directory Certificate Services, cert-manager with its own issuer), distribute its root to all your machines' trusted stores (via group policy, MDM, an image), and it issues certificates for internal services. The mechanics are the same: root, intermediate, chain, only the trust is limited to your organization. The EAP-TLS for Wi-Fi from part three and the IKEv2 certificates from part nineteen work the same way.

A self-signed certificate is the special case of a CA made of a single certificate: it works if that exact certificate is put into the client's trusted store; otherwise the browser is right not to trust it.

Revocation and transparency

A key leaked, a domain changed owners, a CA issued a certificate by mistake — the certificate has to be revoked before it expires. There are two mechanisms, both imperfect:

  • CRL — the CA publishes a list of revoked serial numbers; the client has to download it and keep it fresh. The lists are big and rarely downloaded.
  • OCSP — the client asks the CA over HTTP about a specific certificate. It is slow (an extra request on every connection), reveals to the CA which sites you visit, and is unreliable: if the OCSP server is unavailable, browsers skip the check (soft-fail), otherwise the internet would go down along with the CA. OCSP stapling — the server itself attaches a fresh OCSP response to the handshake; this removes the request and the leak, but not the soft-fail. Let's Encrypt ended OCSP in 2025 in favor of CRLs.

In the end browsers distribute aggregated revocation lists themselves (CRLSets in Chrome, CRLite in Firefox), and short certificate lifetimes make revocation less critical: in 90 days the certificate expires by itself.

Certificate Transparency solves a different problem: how do you find out that some CA has issued a certificate for your domain without your knowledge? A CA must record every certificate it issues in public logs — append-only and cryptographically verifiable; Chrome and Safari reject a certificate without stamps (SCTs) from the logs. Anyone can watch the logs for their domain (crt.sh), and issuing "on the quiet" has become impossible. A side effect: all your internal names for which you obtained public certificates are visible on crt.sh to the whole world.


Client certificates

The server can require a certificate from the client too: in the handshake it sends a CertificateRequest, the client answers with its own Certificate and CertificateVerify, and the server checks them against its CA. This is mTLS (mutual TLS), and it is used to authenticate not people but machines and services: microservices to each other, agents to a management server, devices to Wi-Fi (EAP-TLS) and VPNs. No passwords are needed at all.


Typical errors

The browser says What it really means
NET::ERR_CERT_DATE_INVALID the certificate has expired — auto-renewal broke; or the client's clock is off
ERR_CERT_COMMON_NAME_INVALID the name you came by is not in the SAN: you went to example.com, but the certificate is for www.example.com
ERR_CERT_AUTHORITY_INVALID the chain cannot be built: the server did not send the intermediate, or the root is unknown to the client, or the certificate is self-signed
another site's certificate the client sent no SNI (an old client, access by IP), and the server returned the default certificate
ERR_SSL_VERSION_OR_CIPHER_MISMATCH versions or ciphers did not match: the server has only TLS 1.0, or the client only 1.3
SSL_ERROR_RX_RECORD_TOO_LONG something other than TLS answers on port 443: usually unencrypted HTTP

What TLS does not hide

  • The server name — SNI in the ClientHello is in the clear. The ECH (Encrypted Client Hello) extension encrypts it with a key published in DNS and is gradually appearing, but not everywhere yet.
  • The server's IP address and the fact of connecting to it.
  • The DNS query, if it went over plain UDP (part 12).
  • The size and timing of records: from them it is sometimes possible to guess which page is being viewed.
  • The content — from the server itself. TLS protects the channel, not the data after it.

And one place where TLS is broken on purpose: corporate proxies for "inspection". The company's root is installed on all work machines, and the proxy issues them a certificate for any name on the fly and stands in the middle: one TLS from the browser to the proxy, another from the proxy to the site, and the proxy reads everything. Technically this is exactly what TLS protects against; it only works because the root is trusted. You can tell by the Issuer in any site's certificate: if it shows your company's name instead of Let's Encrypt, you are being inspected.


Hands-on

The handshake, the chain and the verification result in one command:

$ openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null
CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = E6
verify return:1
depth=0 CN = www.example.com
verify return:1
---
Certificate chain
 0 s:CN = www.example.com
   i:C = US, O = Let's Encrypt, CN = E6
 1 s:C = US, O = Let's Encrypt, CN = E6
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
SSL handshake has read 2843 bytes and written 393 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 256 bit
Peer signing digest: SHA256
Peer signature type: ECDSA
...

Reading it: a chain of two certificates (the server sent the intermediate, the root was taken from the store), Verification: OK, TLS 1.3, the cipher, an ECDSA signature. If Verification is not OK, the reason will be there: unable to get local issuer certificate (no intermediate or root), certificate has expired, and so on. The -servername option is mandatory: without SNI the server returns its default certificate.

The dates, for checking expiry from a script:

$ openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates
notBefore=Sep 15 10:00:00 2026 GMT
notAfter=Dec 14 10:00:00 2026 GMT

curl -v https://www.example.com shows the same more briefly: the version, cipher, Subject, Issuer, expiry, SSL certificate verify ok. curl -k disables verification — for diagnostics, not for real life.

In Wireshark a 1.3 handshake looks like a fully decoded ClientHello (extensions, SNI, key_share), a ServerHello — and then only Application Data: the certificate is already encrypted. To see everything, set the SSLKEYLOGFILE environment variable for the browser to a file path: the browser writes the session keys there, and Wireshark, given the file in its TLS settings, decrypts the capture. This is the practical proof that the session keys are not in the certificate.

The issuance history for a domain is at https://crt.sh/?q=example.com. A full check of a server for weak versions and ciphers is testssl.sh www.example.com.


Key takeaways

  • TLS is protection for one TCP connection, chosen by the application. Records of up to 16 KB, types handshake, application data, alert.
  • The 1.3 handshake is one round trip: ClientHello (versions, ciphers, key_share, SNI, ALPN) → ServerHello (the choice, key_share) → everything after that is encrypted: the certificate, CertificateVerify (a signature of the handshake with the private key), Finished. The keys come from ephemeral DH; the certificate's private key only signs: forward secrecy.
  • A certificate is a public key, names in the SAN, a validity period, the issuer's signature. The client builds a chain to a root from its store; the server must send the intermediates.
  • A CA verifies ownership of the name (DV) and signs the CSR. Let's Encrypt does this for free and automatically via ACME: HTTP-01 with a file on the site, DNS-01 with a TXT record (wildcards and internal names); 90 days, renewal after 60.
  • Revocation via CRL and OCSP works poorly, a short lifetime matters more; Certificate Transparency makes all issuances public.
  • SNI, the address and sizes are visible. Corporate inspection is a sanctioned MITM via your own root.

If anything in the series turned out to be unclear or wrong, comments are open under every part.

Only registered users can leave a comment.

Sign in or register to leave a comment.