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.
Đây là Phần 7 của series 10 bài về lập trình mạng với Node.js + TypeScript. Phần 2–6 đã xây TCP, UDP, DNS, HTTP và WebSocket — tất cả đều gửi plaintext trên đường truyền. 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 đó. Ở 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. Hôm nay ta sửa bằng TLS — tầng mã hóa biến http:// thành https://.
Vì sao plaintext không an toàn
Kết nối TCP (Phần 2) là ống byte tin cậy giữa hai chương trình. HTTP (Phần 5) đóng khung request/response trên ống đó. 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
Kẻ tấn công trên đường đi có thể:
- Nghe lén — đọc mật khẩu, token, cookie, API key đang truyền.
- Sửa đổi — đổi body response hoặc redirect bạn tới trang phishing.
- Giả mạo — giả làm server nếu không có cách xác minh danh tính.
TLS giải quyết cả ba.
TLS mang lại gì
TLS (Transport Layer Security; tên cũ SSL) bọc luồng byte của bạn bằng mật mã. Sau bắt tay ngắn, mọi byte đều:
| Guarantee | Ý nghĩa | Không có TLS |
|---|---|---|
| Bí mật | Payload mã hóa; người nghe thấy nhiễu | Toàn plaintext trên dây |
| Toàn vẹn | Sửa đổi bị phát hiện qua MAC / AEAD | Byte có thể bị đổi khi truyền |
| Xác thực | Server chứng minh danh tính bằng certificate | Bạn tin ai trả lời trên IP:port đó |
Ý chính: TLS nằm giữa TCP và giao thức ứng dụng — HTTP thành HTTPS, TCP thô thành “TLS socket”.
Bắt tay TLS
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. 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 liệt kê phiên bản TLS và cipher suite hỗ trợ, có thể gửi SNI (hostname).
- ServerHello + certificate — server chọn tham số và gửi certificate X.509 (khóa công khai + tuyên bố danh tính).
- 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.
- Kênh mã hóa — HTTP, WebSocket hoặc byte thô chảy trong bản ghi TLS.
SNI: hostname trong ClientHello cho phép một IP:443 phục vụ nhiều site với certificate khác nhau.
Certificate và chuỗi tin cậy
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 — root CA có sẵn trong trust store OS / Node; chuỗi xác minh tự động trên production.
- 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ệ.
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. Private key phải giữ bí mật; chỉ server trình certificate.
Tạo cert self-signed cho dev local
Server TLS Node cần cặp key + cert trên đĩa. 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"
Giữ localhost-key.pem khỏi git — thêm *.pem vào .gitignore. Chạy lại trước khi hết hạn hoặc khi rotate key.
Server TLS với node:tls
tls.createServer giống net.createServer nhưng bọc mỗi kết nối trong TLS. 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 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
Client TLS với tls.connect
Client xác minh cert server với trust store hệ thống. 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);
});
Chạy server ở terminal một, client ở terminal hai. 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.
Server HTTPS với node:https
HTTPS là HTTP (Phần 5) trên TLS. 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}
Dùng -k / --insecure chỉ cho cert self-signed local. Trên production, curl https://api.example.com xác minh chuỗi tự động.
Production: terminate TLS ở edge
Hầu hết app Node production không gọi https.createServer ra internet công cộng. Thay vào đó:
Client ──TLS──► nginx / Caddy / cloud LB ──plain HTTP──► Node :3000
(cert from Let's Encrypt)
- Reverse proxy hoặc cloud load balancer terminate TLS, xử lý HTTP/2, gia hạn cert.
- Let’s Encrypt cấp cert CA-signed miễn phí qua ACME (thường tự động bởi Caddy hoặc certbot).
- Process Node lắng nghe HTTP thường trên port nội bộ — TLS đã được gỡ. Vẫn dùng TLS giữa các service khi traffic đi qua mạng không tin cậy.
Khi tự terminate trong Node (API nhỏ, app WebSocket nặng), lưu key trong secrets manager, không trong repo.
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 |
Cả ba đều dùng bắt tay TCP bên dưới (Phần 2). HTTPS thêm ngữ nghĩa HTTP trên TLS.
Lỗi người mới hay mắc
- 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ọ.
- Đặt
rejectUnauthorized: 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. - Commit private key vào git — ai có quyền repo có thể giả mạo server.
- 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.
- 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).
Bài tập
Thử từng bài trước khi mở lời giải.
- Tạo
localhost-key.pemvàlocalhost-cert.pem, chạy TLS echo server, kết nối bằngopenssl s_client. - Chạy TLS client có và không có
ca— ghi lại lỗi chính xác khi bỏca. - Mở rộng handler HTTPS trả
404JSON cho path không tồn tại và kiểm tra bằngcurl -k -i.
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.pemKhông có ca trong client, Node ném Error: self-signed certificate (hoặc UNABLE_TO_VERIFY_LEAF_SIGNATURE) khi bắt tay. 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"}Điều cốt lõi
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. Dùng node:tls cho socket thô mã hóa và node:https cho HTTP; cert self-signed chỉ cho dev local. 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. Tiếp theo: Phần 8 — đóng khung message trên luồng byte.