jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Network Programming · Part 7 — TLS & HTTPS: Encrypting the Wire

Why plaintext HTTP is unsafe, how TLS encrypts and authenticates, the handshake, certificates, self-signed dev certs, node:tls and node:https servers — bilingual with runnable TypeScript examples.

This is Part 7 of a 10-part series on network programming with Node.js + TypeScript {Đây là Phần 7 của series 10 bài về lập trình mạng với Node.js + TypeScript}. Parts 2–6 built TCP, UDP, DNS, HTTP, and WebSockets — all of them send plaintext over the wire {Phần 2–6 đã xây TCP, UDP, DNS, HTTP và WebSocket — tất cả đều gửi plaintext trên đường truyền}. Anyone on the path — your Wi-Fi router, your ISP, a compromised hop — can read and modify those bytes {Bất kỳ ai trên đường đi — router Wi-Fi, ISP, một hop bị xâm nhập — đều có thể đọc và sửa các byte đó}. In Part 10 we will capture packets with tcpdump / Wireshark and see exactly how naked HTTP looks {Ở Phần 10 ta sẽ bắt packet bằng tcpdump / Wireshark và thấy HTTP trần trụi trông thế nào}. Today we fix that with TLS — the encryption layer that turns http:// into https:// {Hôm nay ta sửa bằng TLS — tầng mã hóa biến http:// thành https://}.


Why plaintext is unsafe {Vì sao plaintext không an toàn}

A TCP connection (Part 2) is a reliable byte pipe between two programs {Kết nối TCP (Phần 2) là ống byte tin cậy giữa hai chương trình}. HTTP (Part 5) frames request/response text on top of that pipe {HTTP (Phần 5) đóng khung request/response trên ống đó}. Neither layer adds secrecy {Không tầng nào thêm bí mật}.

Your laptop ── Wi-Fi AP ── ISP ── backbone ── datacenter ── server
              ↑ anyone here can read: GET /login password=secret

Attackers on the path can {Kẻ tấn công trên đường đi có thể}:

  • Eavesdrop — read passwords, tokens, cookies, API keys in flight {Nghe lén — đọc mật khẩu, token, cookie, API key đang truyền}.
  • Tamper — change a response body or redirect you to a phishing page {Sửa đổi — đổi body response hoặc redirect bạn tới trang phishing}.
  • Impersonate — pretend to be the server if there is no way to verify identity {Giả mạo — giả làm server nếu không có cách xác minh danh tính}.

TLS addresses all three {TLS giải quyết cả ba}.


What TLS provides {TLS mang lại gì}

TLS (Transport Layer Security; older name SSL) wraps your byte stream in cryptography {TLS (Transport Layer Security; tên cũ SSL) bọc luồng byte của bạn bằng mật mã}. After a short handshake, every byte is {Sau bắt tay ngắn, mọi byte đều}:

GuaranteeWhat it means {Ý nghĩa}Without TLS {Không có TLS}
Confidentiality {Bí mật}Payload encrypted; observers see noise {Payload mã hóa; người nghe thấy nhiễu}Full plaintext on the wire {Toàn plaintext trên dây}
Integrity {Toàn vẹn}Tampering detected via MAC / AEAD {Sửa đổi bị phát hiện qua MAC / AEAD}Bytes can be changed in transit {Byte có thể bị đổi khi truyền}
Authentication {Xác thực}Server proves identity with a certificate {Server chứng minh danh tính bằng certificate}You trust whoever answers on that IP:port {Bạn tin ai trả lời trên IP:port đó}

Key idea {Ý chính}: TLS sits between TCP and your application protocol — HTTP becomes HTTPS, raw TCP becomes “TLS socket” {TLS nằm giữa TCP và giao thức ứng dụng — HTTP thành HTTPS, TCP thô thành “TLS socket”}.


The TLS handshake {Bắt tay TLS}

Before application data flows, client and server negotiate cipher suite, exchange keys, and verify the server’s certificate {Trước khi dữ liệu ứng dụng chảy, client và server thương lượng cipher suite, trao đổi khóa, và xác minh certificate của server}. You never implement this by hand — node:tls and node:https do it — but the sequence explains connection latency and error messages {Bạn không tự implement — node:tlsnode:https lo — nhưng trình tự giải thích độ trễ kết nối và thông báo lỗi}.

Client Server ClientHello (ciphers) ServerHello + certificate key exchange · verify cert encrypted channel ready certificate proves identity; keys encrypt everything after
ClientHello → ServerHello + certificate → key exchange & verify → encrypted channel
  1. ClientHello — client lists supported TLS versions and cipher suites, may send SNI (hostname) {ClientHello — client liệt kê phiên bản TLS và cipher suite hỗ trợ, có thể gửi SNI (hostname)}.
  2. ServerHello + certificate — server picks parameters and sends its X.509 certificate (public key + identity claims) {ServerHello + certificate — server chọn tham số và gửi certificate X.509 (khóa công khai + tuyên bố danh tính)}.
  3. Key exchange & verify — client checks the cert chain, generates session keys, both sides switch to encrypted records {Trao đổi khóa & xác minh — client kiểm tra chuỗi cert, tạo khóa phiên, cả hai chuyển sang bản ghi mã hóa}.
  4. Encrypted channel — HTTP, WebSocket, or raw bytes flow inside TLS records {Kênh mã hóa — HTTP, WebSocket hoặc byte thô chảy trong bản ghi TLS}.

SNI (Server Name Indication): the hostname in ClientHello lets one IP:443 host serve many sites with different certificates {SNI: hostname trong ClientHello cho phép một IP:443 phục vụ nhiều site với certificate khác nhau}.


Certificates & the chain of trust {Certificate và chuỗi tin cậy}

A certificate binds a public key to a name (e.g. api.example.com) and is signed by someone vouching for that binding {Certificate gắn khóa công khai với một tên (vd api.example.com) và được ký bởi ai đó bảo chứng liên kết đó}.

Your browser / Node client
    trusts → Intermediate CA (signed by Root)
                 signs → Server cert for api.example.com
  • CA-signed (Let’s Encrypt, DigiCert, …) — root CAs ship in OS / Node trust store; chain verifies automatically in production {CA-signed — root CA có sẵn trong trust store OS / Node; chuỗi xác minh tự động trên production}.
  • Self-signed — you sign your own cert; fine for local dev, browsers and Node reject it unless you add an exception {Self-signed — bạn tự ký cert; ổn cho dev local, trình duyệt và Node từ chối trừ khi bạn thêm ngoại lệ}.

What a cert proves: “this public key belongs to this hostname (and org)” — not that the app code is bug-free {Cert chứng minh: “khóa công khai này thuộc hostname (và tổ chức) này” — không chứng minh code app không lỗi}. The private key must stay secret; only the server presents the certificate {Private key phải giữ bí mật; chỉ server trình certificate}.


Generate a self-signed cert for local dev {Tạo cert self-signed cho dev local}

Node TLS servers need a key + cert pair on disk {Server TLS Node cần cặp key + cert trên đĩa}. For localhost, openssl is enough {Cho localhost, openssl là đủ}:

# 365-day RSA key + self-signed cert for localhost
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout localhost-key.pem \
  -out localhost-cert.pem \
  -days 365 \
  -subj "/CN=localhost"

Keep localhost-key.pem out of git — add *.pem to .gitignore {Giữ localhost-key.pem khỏi git — thêm *.pem vào .gitignore}. Re-run before expiry or when rotating keys {Chạy lại trước khi hết hạn hoặc khi rotate key}.


TLS server with node:tls {Server TLS với node:tls}

tls.createServer mirrors net.createServer but wraps each connection in TLS {tls.createServer giống net.createServer nhưng bọc mỗi kết nối trong TLS}. The server always presents the certificate {Server luôn trình certificate}.

import { readFileSync } from 'node:fs';
import { createServer } from 'node:tls';

const PORT = 3443;

const server = createServer(
  {
    key: readFileSync('localhost-key.pem'),
    cert: readFileSync('localhost-cert.pem'),
  },
  (socket) => {
    console.log(
      `TLS client: ${socket.remoteAddress} · cipher: ${socket.getCipher().name}`,
    );

    socket.write('Hello over TLS!\n');

    socket.on('data', (chunk: Buffer) => {
      console.log('encrypted payload received:', chunk.toString('utf8').trim());
      socket.write(`echo: ${chunk.toString('utf8')}`);
    });
  },
);

server.listen(PORT, () => {
  console.log(`TLS server listening on port ${PORT}`);
});

Test from the shell (accepts self-signed with -k) {Test từ shell (chấp nhận self-signed với -k)}:

openssl s_client -connect localhost:3443 -servername localhost
# type text and press Enter — server echoes inside TLS

TLS client with tls.connect {Client TLS với tls.connect}

The client verifies the server cert against the system trust store {Client xác minh cert server với trust store hệ thống}. For localhost self-signed, pass the same cert as ca (or use rejectUnauthorized: false only in dev — see mistakes below) {Với self-signed localhost, truyền cùng cert làm ca (hoặc dùng rejectUnauthorized: false chỉ khi dev — xem lỗi thường gặp bên dưới)}.

import { readFileSync } from 'node:fs';
import { connect } from 'node:tls';

const socket = connect(
  {
    host: 'localhost',
    port: 3443,
    servername: 'localhost', // SNI — must match cert CN/SAN
    ca: readFileSync('localhost-cert.pem'),
  },
  () => {
    console.log('TLS established:', socket.getCipher().name);
    socket.write('ping from typed client\n');
  },
);

socket.setEncoding('utf8');

socket.on('data', (chunk: string) => {
  console.log('server said:', chunk.trim());
  socket.end();
});

socket.on('error', (err: Error) => {
  console.error('TLS error:', err.message);
});

Run server in one terminal, client in another {Chạy server ở terminal một, client ở terminal hai}. If you omit ca and use a self-signed server, you get UNABLE_TO_VERIFY_LEAF_SIGNATURE — that is TLS doing its job {Nếu bỏ ca với server self-signed, bạn gặp UNABLE_TO_VERIFY_LEAF_SIGNATURE — đó là TLS làm đúng việc}.


HTTPS server with node:https {Server HTTPS với node:https}

HTTPS is HTTP (Part 5) over TLS {HTTPS là HTTP (Phần 5) trên TLS}. https.createServer takes the same key / cert options plus an HTTP request handler {https.createServer nhận cùng option key / cert cộng handler request HTTP}.

import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import type { IncomingMessage, ServerResponse } from 'node:http';

const PORT = 3443;

function handler(req: IncomingMessage, res: ServerResponse): void {
  const body = JSON.stringify({
    ok: true,
    path: req.url,
    secure: req.socket.encrypted, // true — we are inside TLS
  });

  res.writeHead(200, {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body),
  });
  res.end(body);
}

const server = createServer(
  {
    key: readFileSync('localhost-key.pem'),
    cert: readFileSync('localhost-cert.pem'),
  },
  handler,
);

server.listen(PORT, () => {
  console.log(`HTTPS server on https://localhost:${PORT}`);
});
curl -k https://localhost:3443/health
# → {"ok":true,"path":"/health","secure":true}

Use -k / --insecure only for local self-signed certs {Dùng -k / --insecure chỉ cho cert self-signed local}. In production, curl https://api.example.com verifies the chain automatically {Trên production, curl https://api.example.com xác minh chuỗi tự động}.


Production: terminate TLS at the edge {Production: terminate TLS ở edge}

Most production Node apps do not call https.createServer on the public internet {Hầu hết app Node production không gọi https.createServer ra internet công cộng}. Instead {Thay vào đó}:

Client ──TLS──► nginx / Caddy / cloud LB ──plain HTTP──► Node :3000
                (cert from Let's Encrypt)
  • Reverse proxy (nginx, Caddy, Traefik) or cloud load balancer terminates TLS, handles HTTP/2, renews certs {Reverse proxy hoặc cloud load balancer terminate TLS, xử lý HTTP/2, gia hạn cert}.
  • Let’s Encrypt issues free CA-signed certs via ACME (often automated by Caddy or certbot) {Let’s Encrypt cấp cert CA-signed miễn phí qua ACME (thường tự động bởi Caddy hoặc certbot)}.
  • Your Node process listens on plain HTTP on an internal port — TLS already stripped {Process Node lắng nghe HTTP thường trên port nội bộ — TLS đã được gỡ}. Still use TLS between services when traffic crosses untrusted networks {Vẫn dùng TLS giữa các service khi traffic đi qua mạng không tin cậy}.

When you do terminate in Node (small APIs, WebSocket-heavy apps), store keys in a secrets manager, not in the repo {Khi tự terminate trong Node (API nhỏ, app WebSocket nặng), lưu key trong secrets manager, không trong repo}.


TLS vs HTTPS vs plain TCP {TLS so với HTTPS so với TCP thường}

LayerAPIPort conventionYou write
Plain TCPnet.createServerany (e.g. 3000)Raw bytes / custom protocol
TLS sockettls.createServeroften 443 or 3443Raw bytes inside encryption
HTTPShttps.createServer443HTTP handler; TLS built-in

All three use the same TCP handshake underneath (Part 2) {Cả ba đều dùng bắt tay TCP bên dưới (Phần 2)}. HTTPS adds HTTP semantics on top of TLS {HTTPS thêm ngữ nghĩa HTTP trên TLS}.


Mistakes beginners make {Lỗi người mới hay mắc}

  • ❌ Using self-signed certs in production — users get warnings; attackers can ship their own self-signed cert just as easily {Dùng cert self-signed trên production — user thấy cảnh báo; attacker cũng có thể đưa cert self-signed của họ}.
  • ❌ Setting rejectUnauthorized: false to “fix” cert errors and shipping it — disables authentication; use a proper ca or a real cert instead {Đặt rejectUnauthorized: false để “sửa” lỗi cert rồi đưa lên production — tắt xác thực; dùng ca đúng hoặc cert thật}.
  • Committing private keys (*.pem, *.key) to git — anyone with repo access can impersonate your server {Commit private key vào git — ai có quyền repo có thể giả mạo server}.
  • Ignoring cert expiry — Let’s Encrypt certs last 90 days; monitoring / auto-renewal is mandatory {Bỏ qua hết hạn cert — cert Let’s Encrypt 90 ngày; giám sát / gia hạn tự động là bắt buộc}.
  • ❌ Confusing which side presents the certificate — always the server (optional mTLS adds a client cert, out of scope here) {Nhầm bên nào trình certificate — luôn là server (tùy chọn mTLS thêm client cert, ngoài phạm vi bài này)}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. Generate localhost-key.pem and localhost-cert.pem, start the TLS echo server, and connect with openssl s_client -connect localhost:3443 {Tạo localhost-key.pemlocalhost-cert.pem, chạy TLS echo server, kết nối bằng openssl s_client}.
  2. Run the typed TLS client with and without ca: readFileSync('localhost-cert.pem') — note the exact error when ca is omitted {Chạy TLS client có và không có ca — ghi lại lỗi chính xác khi bỏ ca}.
  3. Extend the HTTPS handler to return 404 JSON for unknown paths and verify with curl -k -i https://localhost:3443/missing {Mở rộng handler HTTPS trả 404 JSON cho path không tồn tại và kiểm tra bằng curl -k -i}.
Solution {Lời giải}
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout localhost-key.pem -out localhost-cert.pem \
  -days 365 -subj "/CN=localhost"

npx tsx tls-server.ts   # terminal 1
openssl s_client -connect localhost:3443 -servername localhost
# → verify return code 18 (self-signed) unless you pass -CAfile localhost-cert.pem

Without ca in the client, Node throws Error: self-signed certificate (or UNABLE_TO_VERIFY_LEAF_SIGNATURE) during the handshake {Không có ca trong client, Node ném Error: self-signed certificate (hoặc UNABLE_TO_VERIFY_LEAF_SIGNATURE) khi bắt tay}. Adding ca: readFileSync('localhost-cert.pem') trusts that specific self-signed cert {Thêm ca tin cậy cert self-signed cụ thể đó}.

function handler(req: IncomingMessage, res: ServerResponse): void {
  if (req.url !== '/health') {
    const body = JSON.stringify({ error: 'not found', path: req.url });
    res.writeHead(404, {
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(body),
    });
    res.end(body);
    return;
  }
  const body = JSON.stringify({ ok: true, path: req.url });
  res.writeHead(200, {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body),
  });
  res.end(body);
}
curl -k -i https://localhost:3443/missing
# HTTP/1.1 404 Not Found
# {"error":"not found","path":"/missing"}

Takeaway {Điều cốt lõi}

Plaintext TCP and HTTP leak everything on the wire — TLS adds confidentiality, integrity, and server authentication via certificates and a short handshake {TCP và HTTP plaintext lộ mọi thứ trên dây — TLS thêm bí mật, toàn vẹn và xác thực server qua certificate và bắt tay ngắn}. Use node:tls for encrypted raw sockets and node:https for HTTP; self-signed certs are for local dev only {Dùng node:tls cho socket thô mã hóa và node:https cho HTTP; cert self-signed chỉ cho dev local}. In production, terminate TLS at a reverse proxy with Let’s Encrypt and never disable cert verification in shipped code {Trên production, terminate TLS ở reverse proxy với Let’s Encrypt và không bao giờ tắt xác minh cert trong code đưa lên môi trường thật}. Next up: Part 8 — message framing over the byte stream {Tiếp theo: Phần 8 — đóng khung message trên luồng byte}.