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}:
| Guarantee | What 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:tls và node:https lo — nhưng trình tự giải thích độ trễ kết nối và thông báo lỗi}.
- 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)}.
- 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)}.
- 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}.
- 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}
| Layer | API | Port convention | You write |
|---|---|---|---|
| Plain TCP | net.createServer | any (e.g. 3000) | Raw bytes / custom protocol |
| TLS socket | tls.createServer | often 443 or 3443 | Raw bytes inside encryption |
| HTTPS | https.createServer | 443 | HTTP 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: falseto “fix” cert errors and shipping it — disables authentication; use a propercaor a real cert instead {ĐặtrejectUnauthorized: falseđể “sửa” lỗi cert rồi đưa lên production — tắt xác thực; dùngcađú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}.
- Generate
localhost-key.pemandlocalhost-cert.pem, start the TLS echo server, and connect withopenssl s_client -connect localhost:3443{Tạolocalhost-key.pemvàlocalhost-cert.pem, chạy TLS echo server, kết nối bằngopenssl s_client}. - Run the typed TLS client with and without
ca: readFileSync('localhost-cert.pem')— note the exact error whencais omitted {Chạy TLS client có và không cóca— ghi lại lỗi chính xác khi bỏca}. - Extend the HTTPS handler to return
404JSON for unknown paths and verify withcurl -k -i https://localhost:3443/missing{Mở rộng handler HTTPS trả404JSON cho path không tồn tại và kiểm tra bằngcurl -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.pemWithout 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}.