Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

Our TLS key exchange is post-quantum and the certificate signing it is 2048-bit RSA

PubTrivia runs a quiz night: a host screen on the pub's TV, and a phone at every table that joins by scanning a QR code. That means the interesting part of our TLS setup is not the marketing site, it is thirty phones on

PubTrivia runs a quiz night: a host screen on the pub's TV, and a phone at every table that joins by scanning a QR code. That means the interesting part of our TLS setup is not the marketing site, it is thirty phones on pub WiFi opening fresh connections within about a minute of each other. So I spent an evening reading what our own handshake actually does, with no tool fancier than openssl s_client.

Every command below works against the live site. You do not need an account.

The headline

$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app 2>/dev/null \
    | grep -E "Protocol|Cipher is|Negotiated TLS1.3 group"
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Negotiated TLS1.3 group: X25519MLKEM768

X25519MLKEM768 is the hybrid post-quantum key exchange: classical X25519 and ML-KEM-768 run together, and an attacker has to break both. Our key agreement is already resistant to someone recording traffic today and decrypting it in fifteen years, and nobody at this company did anything to deserve that. It is the default for current Chrome, Firefox and OpenSSL against a server that supports it, and Vercel's edge supports it. I got it three times out of three.

Then the server authenticates that beautiful key exchange with this:

$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app 2>/dev/null \
    | openssl x509 -noout -text | grep -E "Public-Key|Signature Algorithm" | head -2
            Public-Key: (2048 bit)
        Signature Algorithm: sha256WithRSAEncryption

RSA 2048. Not ECDSA, and there is no ECDSA certificate hiding behind a negotiation either:

$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app \
    -sigalgs "ecdsa_secp256r1_sha256" 2>&1 | grep alert
...tls alert handshake failure...SSL alert number 40

A client that will only verify ECDSA signatures cannot talk to us at all. That is not a practical problem, since every real client offers RSA too, but it does mean every single handshake carries a 2048-bit signature and a 1,295-byte leaf certificate instead of a P-256 signature and a certificate half the size. The comparison is one hop away inside our own product: the WebSocket server the phones connect to runs on Railway, and its certificate is ECDSA P-256. Same company, same quiz, two vendors, two key types, and the phone in your hand does one of each.

What the handshake costs

$ curl -s -o /dev/null -w "%{time_connect} %{time_appconnect}\n" https://pub-trivia.app/robots.txt
0.016773 0.053092

17 ms to open the TCP connection, 53 ms to finish the TLS handshake, so the handshake itself is about 37 ms over a 17 ms round trip. TLS 1.3 is supposed to be one round trip. Two round trips' worth of time is what a larger key share tends to cost when it no longer fits the pattern the first flight was tuned for, and I am not going to pretend I can see which packet split from out here.

Resumption works, so a returning phone skips it:

$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app \
    -sess_out s.pem >/dev/null 2>&1
$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app \
    -sess_in s.pem 2>/dev/null | grep -E "Reused|New,"
Reused, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256

0-RTT does not:

Max Early Data: 0

Which is the right answer. Early data can be replayed, and I would rather not reason about which of our requests are safe to replay in order to save a round trip on a marketing page.

The chain has a root in it

$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app -showcerts 2>/dev/null \
    | grep -c "BEGIN CERTIFICATE"
3

Three certificates, and this is who they are:

# subject issuer valid
1 *.pub-trivia.app Let's Encrypt YR1 30 Oct 2026 to 28 Jan 2027
2 Let's Encrypt YR1 ISRG Root YR Sep 2025 to Sep 2028
3 ISRG Root YR ISRG Root X1 May 2026 to Sep 2032

Sending a root certificate in the chain is normally a waste of 1,528 bytes, because a client that does not already trust the root will not start trusting it because the server said so. This one is not a waste: the third certificate is the new ISRG root cross-signed by the old one, which is how a CA rotates roots without breaking clients whose trust store has not been updated in two years. Old client, old root, chain still validates. New client, new root, the third certificate is ignored. The whole chain is 4,070 bytes on the wire and about a third of it exists for the benefit of devices in a drawer.

The leaf is a wildcard, *.pub-trivia.app plus the bare apex in the SAN. Two consequences worth knowing: it had to be issued over a DNS challenge rather than an HTTP one, and every subdomain we have never created is already covered by a valid certificate.

There is nowhere to ask whether it is revoked

$ openssl x509 -in leaf.pem -noout -ext authorityInfoAccess,crlDistributionPoints
Authority Information Access:
    CA Issuers - URI:http://yr1.i.lencr.org/
X509v3 CRL Distribution Points:
    Full Name:
      URI:http://yr1.c.lencr.org/68.crl

No OCSP - URI line. There is no OCSP responder for this certificate, and consequently no stapling:

$ echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app -status 2>/dev/null \
    | grep -A1 "OCSP response"
OCSP responses: no responses sent

This is not a misconfiguration on our side, and it is not something Vercel chose. Let's Encrypt has moved revocation off OCSP and onto CRLs, so new certificates carry a CRL distribution point and nothing else. OCSP leaked which sites a browser was visiting to the CA, and browsers had largely stopped doing soft-fail OCSP anyway, so almost nothing is lost in practice. It is still a strange thing to see on your own production certificate the first time: the only live revocation mechanism is a 68-numbered CRL shard that your browser probably downloads on a schedule and that your curl will never look at.

The two signed timestamps, and who signed them

Certificate Transparency requires the certificate to carry proof it was logged. Ours carries two SCTs, and I expected both to be Google. Neither is. Decoding the log IDs against Chrome's own log list:

log ID prefix operator log
1C:9F:68:2C… Sectigo Tiger2027h1
1A:8B:9D:6B… IPng Networks Gouda2027h1

Two independent operators, which is the policy requirement, and both log names end in 2027h1: logs are sharded by the half-year the certificate expires in, so our January 2027 expiry decided which logs would take it. One of them is run by a small Dutch network operator and is named after a cheese. That is the ecosystem actually holding up the padlock in your address bar.

One more detail I enjoyed. The SCT timestamps are Oct 30 16:05:40 2026, and the certificate's notBefore is Oct 30 15:07:10 2026, which is 58 minutes earlier than the proof it was logged. Certificates are routinely backdated by an hour so that a client with a slightly wrong clock does not reject a brand new certificate. The precertificate was logged first, the real one was signed afterwards, and the timestamps read in the wrong order because of it.

What the whole thing refuses

$ for v in tls1 tls1_1 tls1_2 tls1_3; do
    printf "%-8s " $v
    echo | openssl s_client -connect pub-trivia.app:443 -servername pub-trivia.app -$v 2>&1 \
      | grep -E "^(New|.*no protocols available)" | head -1
  done
tls1     ...no protocols available...
tls1_1   ...no protocols available...
tls1_2   New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
tls1_3   New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256

TLS 1.0 and 1.1 are gone, which is the platform's doing rather than ours. 1.2 is still there, which matters for exactly the case this product cares about: the regular at table four whose phone has not had an update since 2019 still gets to join the quiz, with a classical key exchange and no post-quantum anything.

Three CAs are allowed to issue for us, and one did:

$ dig +short pub-trivia.app CAA
0 issue "pki.goog"
0 issue "sectigo.com"
0 issue "letsencrypt.org"

What I would change

Nothing in the certificate, because none of it is ours to set: Vercel issues it, renews it, and picks the key type. The one thing I would ask for is an ECDSA leaf, which would cut the handshake bytes roughly in half for the thirty phones that all connect within a minute of a quiz starting.

The rest of this post is the sort of thing you only find by pointing openssl at your own domain for twenty minutes, which I recommend doing, because the answer is never quite the diagram you have in your head. Ours is public: run the commands above against pub-trivia.app, or scan a table QR at a quiz and watch a phone do the ECDSA one instead. If you want to see the product rather than the padlock, the pricing page is behind the same certificate and the free tier needs no card.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.