tls.explainer connection secured · TLS 1.3
Interactive explainer · Network security

How TLS secures
the internet

Every time you see the padlock in your browser, a sophisticated cryptographic handshake has just happened. Here is every step — with runnable JavaScript.

01
ClientHello
ciphers + random
02
ServerHello
chosen cipher + key share
03
Certificate
server identity
04
Key derivation
DH shared secret
05
Finished
verify handshake
06
App data
encrypted records
TLS 1.3 Diffie-Hellman X.509 certificates AEAD ciphers Perfect forward secrecy HTTPS HSTS
§01

Why plaintext HTTP is dangerous

HTTP sends every byte — your passwords, cookies, credit card numbers — as raw readable text across a network. Anyone with access to any router between you and the server can read it. This isn't theoretical: coffee shop Wi-Fi, ISPs, and government surveillance systems all routinely capture HTTP traffic.

A password submitted over HTTP is visible to every network device it passes through. Not "potentially visible" — literally visible, in plain ASCII, in the TCP packet payload.
Packet interception — click to see what an attacker sees
sniffing.js — what a network sniffer seesJS
// Simulating what a passive network observer captures
// In reality this is exactly what tools like Wireshark show on HTTP traffic

function simulateHTTP(request) {
  // HTTP sends headers and body as raw text over TCP
  const rawPacket = [
    `POST /login HTTP/1.1`,
    `Host: ${request.host}`,
    `Content-Type: application/x-www-form-urlencoded`,
    `Cookie: session_id=${request.cookie}`,
    ``,
    `username=${request.username}&password=${request.password}`,
  ].join("\r\n");
  return rawPacket; // every router sees this
}

function simulateHTTPS(request) {
  // After TLS, the network only sees encrypted opaque bytes
  // The attacker can see: server IP, approximate data size, timing
  // The attacker CANNOT see: headers, path, cookies, body, anything meaningful
  const recordHeader = {
    contentType: 0x17, // 23 = Application Data
    version:     0x0303, // TLS 1.2 record layer (TLS 1.3 uses this for compat)
    length:      512,    // encrypted + padded to hide true size
  };
  // The payload is AEAD-encrypted — looks like random bytes to attacker
  const encryptedPayload = Array.from({length: 32},
    () => Math.floor(Math.random() * 256).toString(16).padStart(2, '0')
  ).join(' ');
  return { recordHeader, encryptedPayload, visibleToAttacker: "destination IP only" };
}

const request = {
  host: "mybank.com",
  username: "alice",
  password: "hunter2",
  cookie: "abc123def456",
};

console.log("=== HTTP (what the attacker sees) ===");
console.log(simulateHTTP(request));

console.log("\n=== HTTPS (what the attacker sees) ===");
const tls = simulateHTTPS(request);
console.log("TLS Record Header:", JSON.stringify(tls.recordHeader));
console.log("Encrypted payload: ", tls.encryptedPayload);
console.log("Visible to attacker:", tls.visibleToAttacker);
console.log(`Password "${request.password}" is: completely hidden`);
§02

What TLS provides

TLS (Transport Layer Security) is a protocol that wraps any stream-based connection — most commonly HTTP — in three distinct security guarantees. Each one addresses a different attack.

PropertyWhat it meansAttack it preventsMechanism
ConfidentialityData can only be read by sender and receiverPassive eavesdropping (wiretapping, packet capture)Symmetric encryption (AES-GCM)
IntegrityData cannot be modified in transit undetectedActive manipulation (bit-flipping, injection)Message authentication codes (HMAC / AEAD)
AuthenticationServer's identity is verifiedImpersonation, MITM (man-in-the-middle)X.509 certificates + PKI trust chain

Without confidentiality: attacker reads your data. Without integrity: attacker modifies it silently (inject malware into a downloaded file). Without authentication: attacker pretends to be your bank — even with encryption, you'd be securely talking to the wrong server.

TLS 1.3 (RFC 8446, 2018) redesigned the protocol from scratch: it removed all legacy weak cryptography, reduced the handshake from 2 round-trips to 1, and made forward secrecy mandatory. Record confidentiality and integrity come from AEAD (Authenticated Encryption with Associated Data); authentication comes from certificates, signatures, and trust-chain validation.
§03

Symmetric vs asymmetric encryption

Two completely different types of cryptography work together in TLS. Understanding why both are needed is the key to understanding the whole protocol.

Symmetric encryption — one key for both sides

Both parties use the same secret key to encrypt and decrypt. It's fast — AES can encrypt gigabytes per second in hardware. The problem: how do you share the key safely before you have a secure channel? You can't encrypt the key to send it — you'd need a key to do that.

Asymmetric encryption — a key pair

Each party has a public key (freely shared) and a private key (secret). Anything encrypted with the public key can only be decrypted with the private key. Crucially, knowing the public key tells an attacker essentially nothing about the private key — it's a mathematical one-way function (modular exponentiation or elliptic curve multiplication).

Asymmetric crypto is ~100–1000× slower than symmetric. TLS never uses it to encrypt data directly. It uses asymmetric crypto only for the handshake — to negotiate a shared symmetric key. Once that key is established, all data flows through fast symmetric encryption.
crypto_primitives.js — XOR cipher (symmetric) and one-way functionsJS
// Illustrating the core ideas: symmetric uses one key, asymmetric uses a pair
// Real TLS uses AES-256-GCM (symmetric) and X25519 (asymmetric DH)

// === Symmetric: XOR cipher (simplified — real systems use AES) ===
function xorEncrypt(plaintext, key) {
  return [...plaintext].map((c, i) =>
    String.fromCharCode(c.charCodeAt(0) ^ key.charCodeAt(i % key.length))
  ).join("");
}
const xorDecrypt = xorEncrypt; // XOR is its own inverse — same operation

const sharedKey  = "secret42";
const message    = "Hello, server!";
const encrypted  = xorEncrypt(message, sharedKey);
const decrypted  = xorDecrypt(encrypted, sharedKey);
console.log("=== Symmetric ===");
console.log(`Plaintext:  "${message}"`);
console.log(`Ciphertext: ${[...encrypted].map(c=>c.charCodeAt(0).toString(16).padStart(2,'0')).join(' ')}`);
console.log(`Decrypted:  "${decrypted}" ✓`);

// === Asymmetric: RSA-like modular exponentiation (toy parameters) ===
// Real RSA uses 2048-4096 bit numbers; ECDH uses elliptic curves
function modPow(base, exp, mod) {
  // Fast modular exponentiation (square and multiply)
  let result = 1n;
  base = base % mod;
  while (exp > 0n) {
    if (exp % 2n === 1n) result = (result * base) % mod;
    exp = exp / 2n;
    base = (base * base) % mod;
  }
  return result;
}

// Toy RSA: p=61, q=53 → n=3233, e=17, d=2753
const n = 3233n, e = 17n, d = 2753n;
const publicKey  = { n, e }; // shared openly
const privateKey = { n, d }; // kept secret
const plainNum   = 42n;
const cipherNum  = modPow(plainNum, publicKey.e, publicKey.n);  // encrypt with public
const recovered  = modPow(cipherNum, privateKey.d, privateKey.n); // decrypt with private
console.log("\n=== Asymmetric (toy RSA) ===");
console.log(`Public key:  (n=${n}, e=${e})`);
console.log(`Private key: (n=${n}, d=${d})`);
console.log(`Plaintext:   ${plainNum}`);
console.log(`Encrypted:   ${cipherNum}  ← only private key can undo this`);
console.log(`Decrypted:   ${recovered} ✓`);

// Why asymmetric is slow:
const t0 = Date.now();
for (let i = 0; i < 1000; i++) modPow(42n, e, n);
const asymMs = Date.now() - t0;
const t1 = Date.now();
for (let i = 0; i < 1000000; i++) xorEncrypt("hello", "key");
const symMs = Date.now() - t1;
console.log(`\n1000 asymmetric ops:    ${asymMs}ms`);
console.log(`1,000,000 symmetric ops: ${symMs}ms`);
console.log(`Symmetric is ~${Math.max(1, Math.round((1000 * asymMs) / Math.max(1, symMs)))}× faster (even with toy params)`);
§04

Diffie-Hellman key exchange

Diffie-Hellman (1976) solved the fundamental problem: how can two parties who have never met before agree on a shared secret over a public channel, where an eavesdropper hears everything they say?

The answer relies on a mathematical one-way function: modular exponentiation. Computing g^a mod p is easy. Reversing it — finding a given only g^a mod p, g, and p — is computationally infeasible for large numbers. This is the discrete logarithm problem.

Shared public parameters (published openly):
p = large prime   g = generator (primitive root mod p)
Alice (private a, public A):
A = ga mod p    ← easy to compute, hard to reverse
Bob (private b, public B):
B = gb mod p
Exchange A and B publicly. Then:
Alice computes: Ba mod p = gba mod p
Bob computes:  Ab mod p = gab mod p
Same result! Attacker who sees A, B, g, p cannot compute gab mod p efficiently.
Diffie-Hellman — live key exchange with real numbers
diffie_hellman.js — DH key exchange from scratchJS
// Diffie-Hellman Key Exchange — the full algorithm
// Using small numbers for readability; TLS uses 2048-bit primes or elliptic curves

function modPow(base, exp, mod) {
  let result = 1n;
  base = base % mod;
  while (exp > 0n) {
    if (exp & 1n) result = result * base % mod;
    exp >>= 1n;
    base = base * base % mod;
  }
  return result;
}

function dhKeyExchange(p, g) {
  // In TLS 1.3 this uses X25519 (elliptic curve DH with Curve25519)
  // Conceptually identical — just a different one-way function

  // Step 1: each party picks a random private key
  const randBigInt = (max) => BigInt(Math.floor(Math.random() * Number(max - 2n)) + 2);
  const a = randBigInt(p); // Alice's private key — NEVER shared
  const b = randBigInt(p); // Bob's private key — NEVER shared

  // Step 2: compute public keys (the one-way function)
  const A = modPow(g, a, p); // Alice's public key: g^a mod p
  const B = modPow(g, b, p); // Bob's public key:   g^b mod p

  // Step 3: exchange public keys (attacker sees A and B — that's fine)
  console.log(`Public params: p=${p}, g=${g}`);
  console.log(`Alice private: a=${a}  ← secret`);
  console.log(`Bob   private: b=${b}  ← secret`);
  console.log(`Alice public:  A=${A}  ← sent over network`);
  console.log(`Bob   public:  B=${B}  ← sent over network`);

  // Step 4: each party computes the shared secret independently
  const aliceSecret = modPow(B, a, p); // Alice computes B^a mod p = g^(ba) mod p
  const bobSecret   = modPow(A, b, p); // Bob   computes A^b mod p = g^(ab) mod p

  console.log(`\nAlice computes: B^a mod p = ${aliceSecret}`);
  console.log(`Bob   computes: A^b mod p = ${bobSecret}`);
  console.log(`Shared secret matches: ${aliceSecret === bobSecret} ✓`);

  // What the attacker knows: p, g, A, B — but NOT a, b, or the secret
  // To break this: solve the discrete logarithm problem — find a from A=g^a mod p
  // With 2048-bit primes this takes ~2^112 operations — computationally infeasible

  return aliceSecret;
}

// Small prime (real TLS uses RFC 3526 Group 14: a 2048-bit prime)
const p = 23n;  // prime modulus
const g = 5n;   // generator (primitive root mod 23)
dhKeyExchange(p, g);

// ECDH (Elliptic Curve DH) — TLS 1.3 default is X25519
// Instead of g^a mod p, uses point multiplication on an elliptic curve: a·G
// Provides same security with much smaller key sizes (256 bits vs 3072 bits)
console.log("\n--- Key size comparison for equivalent security ---");
console.log("RSA/DH (finite field):  3072 bits for 128-bit security");
console.log("ECDH (elliptic curve):   256 bits for 128-bit security");
console.log("Curve25519 key exchange: ~100µs on modern hardware");

Modern TLS 1.3 uses Elliptic Curve Diffie-Hellman (ECDH), specifically the X25519 curve. The mathematics is the same idea but operates on points on an elliptic curve rather than integers modulo a prime. This gives equivalent security with keys that are 12× smaller, and is resistant to certain attacks that affect finite-field DH.

§05

The TLS 1.3 handshake

TLS 1.3 reduced the handshake to a single round-trip (1-RTT). Here is every message, what it contains, and why it's there. The client sends its key share in the very first message, so the server can derive the session key immediately and start encrypting from its second message onward.

TLS 1.3 handshake — step through each message

Full handshake message flow

CLIENTSERVER
Phase 1 — key exchange (plaintext)
ClientHello
random · ciphers · key_share(X25519)
ServerHello
random · cipher · key_share(X25519)
Phase 2 — server auth (encrypted with handshake keys)
EncryptedExtensions
Certificate
X.509 cert chain
CertificateVerify
signature over handshake transcript
Finished
HMAC of transcript
Phase 3 — client auth + app data (encrypted with app keys)
Finished
HMAC of transcript
Application Data (HTTP request/response)
tls_handshake.js — key derivation with HKDFJS
// TLS 1.3 key derivation schedule (simplified)
// Real TLS uses HKDF-Extract and HKDF-Expand with SHA-256
// We simulate the structure to show how multiple keys derive from one shared secret

function simpleHash(data) {
  // Toy hash — real TLS uses SHA-256
  let h = 0x811c9dc5;
  for (const c of data)
    h = Math.imul(h ^ c.charCodeAt(0), 0x01000193) >>> 0;
  return h.toString(16).padStart(8, '0');
}

function hkdfExtract(salt, ikm) {
  // HKDF-Extract: combine salt and input key material
  return simpleHash(salt + "|" + ikm);
}

function hkdfExpand(prk, label, length) {
  // HKDF-Expand: derive a specific sub-key with a label
  return simpleHash(prk + "|tls13 " + label).repeat(Math.ceil(length/8)).slice(0, length);
}

// === TLS 1.3 Key Schedule ===
const dhSharedSecret = "abc123def456"; // result of ECDH key exchange
const clientRandom   = "client_rand_32bytes";
const serverRandom   = "server_rand_32bytes";
const transcript     = clientRandom + serverRandom; // hash of all handshake messages

console.log("=== TLS 1.3 Key Derivation Schedule ===");

// 1. Early secret (from pre-shared key, or zeros for fresh handshake)
const earlySecret = hkdfExtract("0000", "0000");
console.log(`Early secret:       ${earlySecret}`);

// 2. Handshake secret — derived from DH shared secret
const handshakeSecret = hkdfExtract(earlySecret, dhSharedSecret);
console.log(`Handshake secret:   ${handshakeSecret}`);

// 3. Derive client and server handshake traffic keys
const clientHsKey = hkdfExpand(handshakeSecret, "c hs traffic" + transcript, 32);
const serverHsKey = hkdfExpand(handshakeSecret, "s hs traffic" + transcript, 32);
console.log(`Client hs key:      ${clientHsKey}  ← encrypts client Finished`);
console.log(`Server hs key:      ${serverHsKey}  ← encrypts Certificate, CertVerify, Finished`);

// 4. Master secret — mixed with zeros (no further secret input in basic handshake)
const masterSecret = hkdfExtract(handshakeSecret, "0000");
console.log(`Master secret:      ${masterSecret}`);

// 5. Application traffic keys — used for all HTTP data after handshake
const clientAppKey = hkdfExpand(masterSecret, "c ap traffic" + transcript, 32);
const serverAppKey = hkdfExpand(masterSecret, "s ap traffic" + transcript, 32);
console.log(`Client app key:     ${clientAppKey}  ← encrypts all outgoing HTTP`);
console.log(`Server app key:     ${serverAppKey}  ← encrypts all incoming HTTP`);

// 6. Exporter master secret (for channel binding, QUIC, etc.)
const exporterSecret = hkdfExpand(masterSecret, "exp master" + transcript, 32);
console.log(`Exporter secret:    ${exporterSecret}`);

console.log("\nAll keys derived from one DH shared secret + handshake transcript.");
console.log("Separate keys for client→server and server→client prevent reflection attacks.");
§06

Certificates and the trust chain

DH key exchange protects against passive eavesdroppers — but what if an attacker intercepts the connection and acts as a "man in the middle", performing DH with both sides separately? You'd be talking to the attacker, and they'd relay everything to the real server, seeing all your data.

Certificates solve this by binding an authentication public key to a verified identity. A Certificate Authority (CA) checks that you actually own the domain, then digitally signs a certificate asserting "this public key belongs to example.com". In TLS 1.3 that certificate key authenticates the server via signatures; the session secret still comes from the separate ephemeral Diffie-Hellman key shares. Your browser ships with ~150 trusted root CA certificates pre-installed.

X.509 certificate structure — click a field to learn more

The certificate chain

Root CAs don't sign individual website certificates — the private key is kept offline. Instead they sign intermediate certificates, which in turn sign the website's leaf certificate. This limits exposure: if an intermediate is compromised, only revoke that intermediate.

Root CA
DigiCert Global Root CA
Pre-installed in browser · 20yr validity · offline private key
↓ signs
Intermediate CA
DigiCert TLS RSA SHA256 2020 CA1
Issued by root · 5yr validity · online but HSM-protected
↓ signs
Leaf Certificate
example.com
Domain-validated · 90 days · served in TLS handshake
certificates.js — certificate chain checks with toy signature verificationJS
// Certificate verification — what the browser does during the TLS handshake
// The signature scheme here is deliberately toy-level: just enough structure to show
// that verification recomputes a signature from cert contents + issuer public key.

function simpleHash(data) {
  let h = 0x811c9dc5;
  for (const c of data)
    h = Math.imul(h ^ c.charCodeAt(0), 0x01000193) >>> 0;
  return h.toString(16).padStart(8, '0');
}

class Certificate {
  constructor({subject, issuer, publicKey, validFrom, validTo, signature}) {
    this.subject    = subject;
    this.issuer     = issuer;
    this.publicKey  = publicKey;   // the server's authentication public key
    this.validFrom  = new Date(validFrom);
    this.validTo    = new Date(validTo);
    this.signature  = signature;   // CA's digital signature over this cert's fields
  }

  signedData() {
    return [
      this.subject,
      this.issuer,
      this.publicKey,
      this.validFrom.toISOString().slice(0, 10),
      this.validTo.toISOString().slice(0, 10)
    ].join("|");
  }

  isExpired(now = new Date()) {
    return now < this.validFrom || now > this.validTo;
  }

  matchesDomain(hostname) {
    // Wildcard matching: *.example.com matches sub.example.com
    if (this.subject === hostname) return true;
    if (this.subject.startsWith("*.")) {
      const base = this.subject.slice(2);
      return hostname.endsWith("." + base) && !hostname.slice(0, -base.length-1).includes(".");
    }
    return false;
  }
}

function toySign(cert, issuerPublicKey) {
  return "sig:" + simpleHash(cert.signedData() + "|" + issuerPublicKey);
}

function verifyCertChain(chain, hostname, trustedRoots, now = new Date()) {
  console.log(`Verifying chain for: ${hostname}`);
  const results = [];

  for (let i = 0; i < chain.length; i++) {
    const cert   = chain[i];
    const issuer = chain[i+1] || trustedRoots.find(r => r.subject === cert.issuer);

    // Check 1: is the certificate expired?
    if (cert.isExpired(now))
      results.push(`  ✗ [${cert.subject}] EXPIRED`);
    else
      results.push(`  ✓ [${cert.subject}] validity period OK`);

    // Check 2: does the leaf cert match the hostname?
    if (i === 0) {
      if (cert.matchesDomain(hostname))
        results.push(`  ✓ [${cert.subject}] hostname matches`);
      else
        results.push(`  ✗ [${cert.subject}] hostname mismatch!`);
    }

    // Check 3: does the signature verify under the issuer's public key?
    if (!issuer) {
      results.push(`  ✗ [${cert.subject}] issuer "${cert.issuer}" not found in trust store`);
    } else if (cert.signature === toySign(cert, issuer.publicKey)) {
      results.push(`  ✓ [${cert.subject}] signature verifies against ${issuer.subject}`);
    } else {
      results.push(`  ✗ [${cert.subject}] invalid signature!`);
    }
  }
  results.forEach(r => console.log(r));
  return !results.some(r => r.includes("✗"));
}

// Simulate a certificate chain
const rootCA = new Certificate({
  subject: "DigiCert Global Root CA", issuer: "DigiCert Global Root CA",
  publicKey: "rootPublicKey_abc123", validFrom: "2006-11-10", validTo: "2031-11-10",
  signature: "",
});
rootCA.signature = toySign(rootCA, rootCA.publicKey);
const intermediate = new Certificate({
  subject: "DigiCert TLS RSA 2020 CA1", issuer: "DigiCert Global Root CA",
  publicKey: "interPublicKey_xyz789", validFrom: "2021-04-14", validTo: "2030-04-13",
  signature: "",
});
intermediate.signature = toySign(intermediate, rootCA.publicKey);
const leaf = new Certificate({
  subject: "*.example.com", issuer: "DigiCert TLS RSA 2020 CA1",
  publicKey: "leafPublicKey_pqr456", validFrom: "2025-01-01", validTo: "2027-01-01",
  signature: "",
});
leaf.signature = toySign(leaf, intermediate.publicKey);

const chain = [leaf, intermediate];
const trusted = [rootCA];

console.log("=== Valid chain ===");
const ok = verifyCertChain(chain, "api.example.com", trusted);
console.log(`Result: ${ok ? "TRUSTED ✓" : "UNTRUSTED ✗"}`);

console.log("\n=== Hostname mismatch ===");
verifyCertChain(chain, "attacker.com", trusted);
§07

HTTPS = HTTP inside TLS

HTTPS is not a separate protocol — it's the same HTTP/1.1 or HTTP/2 running inside a TLS tunnel. From the application's perspective, it just writes to a socket; TLS handles the encryption and authentication transparently.

The URL scheme (https://) and default port (443 instead of 80) tell the client to perform a TLS handshake before sending any HTTP. The server's Content-Security-Policy and Strict-Transport-Security headers can then enforce that future connections also use HTTPS.

HTTP request lifecycle: plaintext vs TLS-wrapped
https.js — layered protocol stackJS
// HTTPS protocol stack: each layer wraps the layer above it
// Real implementations are event-driven (Node.js tls.connect → https.request)

class TLSRecord {
  // TLS wraps arbitrary data into "records" of up to 16KB
  static wrap(data, contentType = "application_data") {
    return {
      type:    contentType,   // 20=change_cipher, 21=alert, 22=handshake, 23=app_data
      version: "TLS 1.2",   // record layer stays 1.2 for compatibility; TLS 1.3 in extension
      length:  data.length,
      payload: "[ENCRYPTED:" + data.slice(0, 20) + "...]", // AEAD encrypted
      authTag: "[16-byte GCM tag]",
    };
  }

  static unwrap(record, sessionKey) {
    // Verify auth tag first — reject if tampered, before decrypting
    return "[decrypted plaintext]"; // real: AES-256-GCM decrypt
  }
}

// Simulate a full HTTPS request/response cycle
function simulateHTTPS(url, method = "GET", body = null) {
  const { hostname, pathname } = new URL(url);

  console.log(`=== HTTPS ${method} ${url} ===\n`);

  // Layer 1: TCP (handled by OS)
  console.log("[TCP] SYN → SYN-ACK → ACK  (1 round trip, ~20ms)");

  // Layer 2: TLS (1 round trip in TLS 1.3)
  console.log("[TLS] ClientHello →");
  console.log("       ← ServerHello + Certificate + Finished");
  console.log("[TLS] → Finished  (handshake complete, ~1 round trip)");
  console.log("[TLS] Session established. Keys derived.");

  // Layer 3: HTTP (now runs inside the TLS tunnel)
  const httpRequest = [
    `${method} ${pathname} HTTP/1.1`,
    `Host: ${hostname}`,
    `User-Agent: Mozilla/5.0`,
    `Accept: application/json`,
    body ? `Content-Length: ${body.length}` : null,
    ``,
    body,
  ].filter(Boolean).join("\r\n");

  console.log("\n[HTTP] Plaintext request (only visible inside TLS tunnel):");
  console.log(httpRequest);

  const tlsRecord = TLSRecord.wrap(httpRequest);
  console.log("\n[TLS] Encrypted record sent on wire:");
  console.log(`  type:    ${tlsRecord.type}`);
  console.log(`  payload: ${tlsRecord.payload}`);
  console.log(`  authTag: ${tlsRecord.authTag}`);

  const httpResponse = `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{"status":"ok"}`;
  console.log("\n[TLS] Server response (decrypted, delivered to HTTP layer):");
  console.log(httpResponse);

  // HSTS: server can tell browser to ALWAYS use HTTPS for this domain
  console.log("\n[HTTP Header] Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
  console.log("  → Browser will refuse to connect via HTTP for the next 365 days");
}

simulateHTTPS("https://api.example.com/user/profile");
console.log("\nTotal overhead vs plain HTTP:");
console.log("  First request:  +1 RTT (TLS handshake) = ~50ms on typical connection");
console.log("  Subsequent:     ~0ms (TLS session resumption / 0-RTT)");
§08

Perfect forward secrecy

Imagine an attacker records all encrypted TLS traffic today, then waits. Three years later they steal the server's private key. With older TLS (pre-1.3, RSA key exchange), they could now decrypt all that captured traffic retroactively — the session keys were derived from the server's long-term private key.

Perfect Forward Secrecy (PFS) prevents this. TLS 1.3 mandates ephemeral Diffie-Hellman: both sides generate fresh, random DH key pairs for every single session. The shared secret is derived from these ephemeral keys, not from the server's long-term certificate key. After the session ends, the ephemeral private keys are destroyed.

Even if an attacker gets the server's private certificate key tomorrow, they cannot decrypt today's sessions — the ephemeral DH keys are gone forever. This is why TLS 1.3 removed RSA key exchange entirely. PFS is now non-optional.
forward_secrecy.js — ephemeral keys and why they matterJS
// Demonstrating forward secrecy: each session gets fresh ephemeral keys
// Old TLS (RSA): session key derived from server's long-term key → NOT forward-secret
// TLS 1.3 (ECDHE): session key derived from ephemeral keys → forward-secret

function modPow(b, e, m) {
  let r = 1n; b %= m;
  while (e > 0n) { if (e & 1n) r = r*b%m; e >>= 1n; b = b*b%m; }
  return r;
}

class Session {
  constructor(id, p, g) {
    this.id = id;
    // Ephemeral DH key pair — generated fresh for this session only
    this.ephemeralPrivate = BigInt(Math.floor(Math.random() * 1000) + 2);
    this.ephemeralPublic  = modPow(g, this.ephemeralPrivate, p);
    this.sessionKey = null;
    this.p = p; this.g = g;
  }

  deriveSharedSecret(peerPublic) {
    this.sessionKey = modPow(peerPublic, this.ephemeralPrivate, this.p);
    return this.sessionKey;
  }

  destroy() {
    // Critical: wipe ephemeral keys from memory after session ends
    console.log(`  Session ${this.id}: wiping ephemeral private key ${this.ephemeralPrivate} from memory`);
    this.ephemeralPrivate = 0n; // gone forever
    this.sessionKey = null;
  }
}

const p = 23n, g = 5n;
const serverLongTermKey = "server_cert_private_key"; // NEVER used for key derivation in TLS 1.3

console.log("=== Three independent sessions (same server, different times) ===");
const sessionKeys = [];
for (let i = 1; i <= 3; i++) {
  const server = new Session(`server-${i}`, p, g);
  const client = new Session(`client-${i}`, p, g);
  const sk = client.deriveSharedSecret(server.ephemeralPublic);
  server.deriveSharedSecret(client.ephemeralPublic);
  console.log(`Session ${i}: key=${sk} (server ephem pub=${server.ephemeralPublic})`);
  sessionKeys.push(sk);
  server.destroy();
  client.destroy();
}

console.log("\nAll session keys are different (fresh ephemeral keys each time):");
console.log(`  ${sessionKeys.join(", ")}`);
console.log(`  Same? ${sessionKeys.every(k => k === sessionKeys[0])}`);

console.log(`\n=== Server's long-term key is compromised ===`);
console.log(`Long-term key: "${serverLongTermKey}"`);
console.log("Can attacker now decrypt past sessions? NO — ephemeral keys were destroyed.");
console.log("Attacker can impersonate the server going forward, but past is safe.");

console.log("\n=== OLD TLS (RSA key exchange, no PFS) ===");
console.log("Client encrypts a random PreMasterSecret using server's certificate public key.");
console.log("Session key is derived from PreMasterSecret.");
console.log("If attacker records traffic NOW and steals server key LATER:");
console.log("  → Decrypt the PreMasterSecret → derive session key → decrypt all traffic. ✗");
§09

Cipher suites and real-world TLS

In TLS 1.2 and earlier, a cipher suite named most of the handshake and record-protection choices together. TLS 1.3 simplifies this sharply: the cipher suite now specifies only the record-protection AEAD and the HKDF hash. The key exchange group (such as X25519) and the authentication algorithm (such as RSA-PSS or ECDSA) are negotiated separately.

TLS 1.3 cipher suiteRecord protection (AEAD)HKDF hashTypical note
TLS_AES_256_GCM_SHA384AES-256-GCMSHA-384High-security GCM option
TLS_AES_128_GCM_SHA256AES-128-GCMSHA-256Common default on AES-accelerated CPUs
TLS_CHACHA20_POLY1305_SHA256ChaCha20-Poly1305SHA-256Often preferred on mobile / no AES acceleration
TLS_AES_128_CCM_SHA256AES-128-CCMSHA-256Niche constrained-device profile
TLS_AES_128_CCM_8_SHA256AES-128-CCM-8SHA-256Very constrained environments; shorter tag
In TLS 1.3, the client and server still negotiate key exchange and authentication separately: for example, X25519 for the ephemeral DH share and rsa_pss_rsae_sha256 or ecdsa_secp256r1_sha256 for certificate signatures.
Legacy TLS 1.2 suitesKey exchangeAuthEncryptionStatus
TLS_RSA_WITH_AES_128_GCM_SHA256RSA (no PFS)RSAAES-128-GCMDeprecated — no forward secrecy
TLS_RSA_WITH_RC4_128_MD5RSARSARC4 (broken)Prohibited — RC4 cryptographically broken
TLS_DHE_RSA_WITH_AES_128_CBC_SHADHERSACBC (BEAST/POODLE risk)Avoid — CBC padding oracle attacks

Certificate Transparency

Since 2018, Chrome requires all certificates to be logged in public Certificate Transparency (CT) logs. Every CA must submit every issued certificate to an append-only, cryptographically verifiable log. This means any certificate misissued by a rogue or compromised CA is publicly visible within seconds — enabling rapid detection and revocation.

HSTS (HTTP Strict Transport Security)

HSTS tells browsers to always use HTTPS for a domain, even if the user types http://. The preload directive goes further — the domain is hardcoded into browser source code, preventing even the first HTTP connection.

real_world_tls.js — AEAD encryption, HSTS, session resumptionJS
// Real-world TLS features: AEAD, session resumption (0-RTT), HSTS

// === AEAD: Authenticated Encryption with Associated Data ===
// AES-GCM provides confidentiality + integrity in one operation
// "Associated data" = TLS record header (authenticated but not encrypted)

function simulateAEAD(key, nonce, plaintext, associatedData) {
  // Real: AES-256-GCM. Simulated with XOR + simple auth tag
  const keyStream = [...plaintext].map((_,i) =>
    String.fromCharCode(key.charCodeAt(i%key.length) ^ nonce.charCodeAt(i%nonce.length)));
  const ciphertext = [...plaintext].map((c,i) =>
    (c.charCodeAt(0) ^ keyStream[i].charCodeAt(0)).toString(16).padStart(2,'0')).join('');

  // Auth tag covers BOTH ciphertext AND associated data (header)
  // Any modification to either invalidates the tag → rejected before decryption
  let tag = 0;
  [...ciphertext, ...(associatedData||'')].forEach(c => tag = (tag*31 + c.charCodeAt(0)) & 0xffff);
  return { ciphertext, tag: tag.toString(16).padStart(4,'0'), nonce };
}

const sessionKey = "derived-session-key";
const seqNum = "000000000001"; // TLS uses sequence number as nonce to prevent replay
const tlsHeader = "17 0303 0028"; // content-type, version, length — NOT encrypted but authenticated
const httpData = "GET /secret HTTP/1.1\r\nHost: bank.com";

const { ciphertext, tag } = simulateAEAD(sessionKey, seqNum, httpData, tlsHeader);
console.log("=== AEAD Encryption ===");
console.log(`Plaintext:   "${httpData}"`);
console.log(`Ciphertext:  ${ciphertext.slice(0,40)}...`);
console.log(`Auth tag:    ${tag}  ← covers ciphertext + TLS header`);
console.log("If attacker flips ANY bit in ciphertext or header: tag mismatch → REJECTED");

// === Session Resumption / 0-RTT ===
console.log("\n=== TLS Session Resumption ===");
const sessionTicket = {
  // Server sends this encrypted ticket at end of handshake
  // Client presents it on next connection — server can skip full handshake
  resumptionSecret: "res_master_secret_xyz",
  maxEarlyData: 16384, // 0-RTT: client can send this many bytes before server Finished
  lifetime: 7200, // seconds
};
console.log(`Session ticket issued: lifetime=${sessionTicket.lifetime}s, 0-RTT allowed=${sessionTicket.maxEarlyData}B`);
console.log("Next connection: client sends early data (0-RTT) with first packet — saves 1 RTT");
console.log("Warning: 0-RTT is NOT forward secret and is replay-vulnerable → use for idempotent GET only");

// === HSTS ===
console.log("\n=== HTTP Strict Transport Security ===");
const hstsHeader = "Strict-Transport-Security: max-age=63072000; includeSubDomains; preload";
console.log(`Header: ${hstsHeader}`);
console.log(`max-age=63072000 → HTTPS required for next ${63072000/86400/365} years`);
console.log("includeSubDomains → applies to *.example.com too");
console.log("preload → submit to hstspreload.org → hardcoded into Chrome/Firefox source");
console.log("Effect: browser converts http:// → https:// BEFORE making any network connection");