Octet

Serve a full certificate chain over TLS 1.3 and check it with openssl and curl

LinuxWorking

Tasks

  1. Confirm the leaf and the intermediate belong together and the leaf covers the right names.
  2. Build a chain file with the leaf first and the intermediate second.
  3. Configure nginx for TLS 1.2 and 1.3 only, HTTP/2 and HSTS, using the chain file.
  4. Prove from PC1 that the server sends both certificates and the chain verifies.
  5. Prove that HTTP/2 is negotiated with ALPN and measure DNS, TCP, TLS and first-byte timing.

Solution

  1. 1Inspect the certificatesWEB1
    WEB1 on Linux
    cd /etc/ssl/shop
    openssl x509 -in shop.example.com.crt -noout -subject -issuer -dates -ext subjectAltName
    openssl x509 -in example-issuing-ca-g2.crt -noout -subject
    openssl verify -untrusted example-issuing-ca-g2.crt shop.example.com.crt

    The leaf's issuer must equal the intermediate's subject. openssl verify with -untrusted uses the intermediate to build the path and the system store for the root; it prints shop.example.com.crt: OK.

  2. 2Build the chain fileWEB1
    WEB1 on Linux
    sudo sh -c 'cat shop.example.com.crt example-issuing-ca-g2.crt > fullchain.pem'
    grep -c 'BEGIN CERTIFICATE' fullchain.pem

    The count must be 2. Order matters, leaf first. Do not add the root.

  3. 3Configure the server blockWEB1
    WEB1 on Linux
    # /etc/nginx/sites-available/shop.conf
    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        http2 on;
        server_name shop.example.com www.shop.example.com;
    
        ssl_certificate     /etc/ssl/shop/fullchain.pem;
        ssl_certificate_key /etc/ssl/shop/shop.example.com.key;
        ssl_protocols       TLSv1.2 TLSv1.3;
    
        add_header Strict-Transport-Security "max-age=31536000" always;
        root /var/www/shop;
    }

    http2 on; needs nginx 1.25.1 or later; older versions use listen 443 ssl http2;. TLS 1.3 cipher suites are fixed by OpenSSL and need no ssl_ciphers line.

  4. 4Test and reloadWEB1
    WEB1 on Linux
    sudo nginx -t
    sudo systemctl reload nginx

    A reload keeps existing connections; a failed nginx -t means the reload would be refused.

Verify

PC1 output
PC1# openssl s_client -connect shop.example.com:443 -servername shop.example.com -alpn h2,http/1.1 </dev/null
CONNECTED(00000003)
depth=2 C=US, O=Example Trust, CN=Example Root CA
verify return:1
depth=1 C=US, O=Example Trust, CN=Example Issuing CA G2
verify return:1
depth=0 CN=shop.example.com
verify return:1
---
Certificate chain
 0 s:CN=shop.example.com
   i:C=US, O=Example Trust, CN=Example Issuing CA G2
   a:PKEY: RSA, 2048 (bit); sigalg: ecdsa-with-SHA256
   v:NotBefore: Nov  7 03:23:08 2026 GMT; NotAfter: Feb  5 03:23:08 2027 GMT
 1 s:C=US, O=Example Trust, CN=Example Issuing CA G2
   i:C=US, O=Example Trust, CN=Example Root CA
   a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
   v:NotBefore: Mar  2 10:00:00 2026 GMT; NotAfter: Mar  1 10:00:00 2031 GMT
---
...
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
...
ALPN protocol: h2
...
Verify return code: 0 (ok)

Two certificates are sent, each i: matches the next s:, the path ends at the trusted root (depth 2), TLS 1.3 is negotiated, ALPN selected h2, and the verify return code is 0.

PC1 output
PC1# curl -sv -o /dev/null https://shop.example.com/
*   Trying 203.0.113.80:443...
* Connected to shop.example.com (203.0.113.80) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=shop.example.com
*  start date: Nov  7 03:23:08 2026 GMT
*  expire date: Feb  5 03:23:08 2027 GMT
*  subjectAltName: host "shop.example.com" matched cert's "shop.example.com"
*  issuer: C=US; O=Example Trust; CN=Example Issuing CA G2
*  SSL certificate verify ok.
> GET / HTTP/2
> Host: shop.example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< server: nginx
< content-type: text/html
< strict-transport-security: max-age=31536000

The TLS 1.3 handshake runs in order: the server's Certificate, CertificateVerify and Finished arrive in one flight after ServerHello. The client's Change cipher spec is the compatibility-mode dummy, not a real cipher change. The name matches the SAN, the chain verifies, ALPN chose h2, and the HSTS header is present.

PC1 output
PC1# curl -o /dev/null -s -w 'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} ttfb %{time_starttransfer}\n' https://shop.example.com/
dns 0.004211 connect 0.031877 tls 0.064502 ttfb 0.097316

The values are cumulative seconds. TCP connect took 27.7 ms (one round trip), the TLS 1.3 handshake another 32.6 ms (one round trip plus crypto), and the first byte 32.8 ms after that (one round trip plus server time). Three round trips before the first byte of content, as expected for TCP plus TLS 1.3.

Mistakes people make

  • Pointing ssl_certificate at the leaf-only file. Browsers may still work, but curl, Java, Python and most API clients fail with "unable to get local issuer certificate".
  • Putting the intermediate before the leaf. The server must send the leaf first.
  • Testing with an IP address and no -servername. Without SNI nginx may answer with its default certificate and you test the wrong thing.
  • Judging the chain from a browser. Browsers repair missing intermediates and hide the problem.
  • Forgetting to reload nginx after nginx -t. The test alone changes nothing.

On other platforms

Apache httpd 2.4.8 and later reads the chain from the same file as the certificate.

Linux
SSLEngine on
SSLCertificateFile    /etc/ssl/shop/fullchain.pem
SSLCertificateKeyFile /etc/ssl/shop/shop.example.com.key
SSLProtocol           -all +TLSv1.2 +TLSv1.3

Say it in the interview

"I check that the leaf's issuer is the intermediate's subject, build a chain file with the leaf first, and point nginx at it with only TLS 1.2 and 1.3 enabled. Then I verify from a client with s_client and SNI: two certificates, each issuer matching the next subject, verify return code 0, ALPN h2. curl -v confirms the handshake and the SAN match, and curl -w splits the time into DNS, TCP, TLS and first byte."