Facebook Pixel

End-to-End Encryption là gì? Triển Khai E2EE với TypeScript và Web Crypto API

03 Jul, 2025

End-to-End Encryption là cơ chế bảo mật trong đó chỉ người liên lạc đầu cuối mới có khả năng truy cập và giải mã nội dung dữ liệu được gửi đi

End-to-End Encryption là gì? Triển Khai E2EE với TypeScript và Web Crypto API

Mục Lục

Bạn đang xây dựng một ứng dụng chat? Một hệ thống chia sẻ tài liệu nội bộ? Hay bất kỳ sản phẩm nào cần xử lý dữ liệu nhạy cảm của người dùng? Nếu câu trả lời là có, thì End-to-End Encryption (E2EE) không phải là một tính năng "nice-to-have", mà là một yêu cầu bắt buộc để bảo vệ người dùng và xây dựng lòng tin.

1. nd-to-End Encryption (E2EE) là gì?

Để triển khai E2EE, chúng ta cần phải hiểu rõ hai cơ chế mã hóa nền tảng:

  • Symmetric Encryption (Mã hóa Đối xứng): Đây là phương pháp sử dụng cùng một secret key cho cả quá trình mã hóa và giải mã. Cơ chế này có tốc độ xử lý nhanh nhưng tồn tại một điểm yếu cốt lõi: làm sao để chia sẻ secret key đó cho người nhận một cách an toàn mà không bị lộ trong quá trình truyền?
Symmetric Encryption
  • Asymmetric Encryption (Mã hóa Bất đối xứng): Cơ chế này giải quyết vấn đề phân phối khóa của mã hóa đối xứng. Mỗi người dùng sở hữu một cặp key:
    • Public Key: Được chia sẻ công khai, dùng để mã hóa dữ liệu hoặc xác thực chữ ký số.
    • Private Key: Được giữ bí mật tuyệt đối, dùng để giải mã dữ liệu đã được mã hóa bằng Public Key tương ứng, hoặc để tạo chữ ký số.
Asymmetric Encryption

Một luồng E2EE tiêu chuẩn thường kết hợp cả hai: sử dụng Asymmetric Encryption để thiết lập một kênh an toàn và trao đổi một shared secret key. Sau đó, shared secret key này được dùng để mã hóa toàn bộ dữ liệu về sau bằng Symmetric Encryption để tận dụng hiệu suất tốc độ.

2. Các rủi ro tiềm ẩn

Mô hình kết hợp trên vẫn tồn tại một lỗ hổng nghiêm trọng. Hãy cùng mình xem xét kịch bản tấn công sau:

  1. Alice yêu cầu Public Key của Bob từ server.
  2. Kẻ tấn công (Eve) chặn yêu cầu này.
  3. Eve gửi Public Key của chính mình cho Alice, nhưng giả mạo là Public Key của Bob.
  4. Alice nhận Public Key giả mạo và dùng nó để mã hóa một session key (khóa phiên) rồi gửi đi.
  5. Eve chặn lại, dùng Private Key của mình để giải mã và lấy được session key.
  6. Tiếp theo, Eve dùng Public Key thật của Bob để mã hóa lại session key này và gửi cho Bob.
  7. Kể từ thời điểm này, Eve có thể giải mã, đọc và sửa đổi toàn bộ tin nhắn giữa Alice và Bob mà cả hai không hề hay biết.

Đây là kịch bản tấn công Man-in-the-Middle (MITM).

3. Trust On First Use (TOFU) & Out-of-Band Authentication

Các ứng dụng E2EE hiện đại không phụ thuộc vào một Certificate Authority (CA) trung gian để giải quyết MITM. Thay vào đó, họ triển khai một cơ chế xác thực do người dùng chủ động thực hiện:

  1. Key Exchange & Fingerprint Generation: Khi hai người dùng bắt đầu một phiên liên lạc, thiết bị của họ trao đổi Public Key và sử dụng một thuật toán (như Diffie-Hellman) để tạo ra một shared secret key. Từ khóa chung này, một "dấu vân tay" định danh cho phiên (thường gọi là Safety Number hoặc Fingerprint) sẽ được tạo ra.
  2. Out-of-Band Authentication: Đây là bước bảo mật quyết định. Người dùng được cung cấp công cụ để tự xác thực rằng Safety Number trên thiết bị của họ và của người nhận là trùng khớp. Việc xác thực này phải được thực hiện qua một kênh liên lạc đáng tin cậy khác (gọi là out-of-band), ví dụ như gặp mặt trực tiếp, gọi điện, hoặc video call để so sánh mã.
  3. Trust On First Use (TOFU): Sau khi xác thực thành công lần đầu, thiết bị sẽ lưu lại Public Key của đối phương và "tin tưởng" nó. Nếu trong tương lai Public Keynày bị thay đổi, ứng dụng sẽ ngay lập tức đưa ra cảnh báo về một rủi ro an ninh tiềm tàng, có thể là một cuộc tấn công MITM.

Cơ chế này trao quyền xác thực cho người dùng, phù hợp với triết lý phi tập trung của E2EE.

4. Hướng Dẫn Triển Khai với TypeScript & Web Crypto API

Ở phần này, chúng ta sẽ mô phỏng quy trình E2EE giữa hai người dùng – Alice và Bob – bằng TypeScript. Công cụ sử dụng là Web Crypto API, một API chuẩn được tích hợp sẵn trong trình duyệt hiện đại và Node.js (từ phiên bản 15.7 trở lên).

Mục tiêu: Mô phỏng việc Alice gửi một tin nhắn đã được mã hóa đầu-cuối (E2EE) tới Bob.

Javascript
// crypto-e2ee-demo.ts

// Trong môi trường Node.js, cần import webcrypto
// import { webcrypto } from 'node:crypto';
// const crypto = webcrypto;

// --- STEP 1: Định nghĩa lớp User ---

class User {
    name: string;
    private privateKey!: CryptoKey;
    publicKey!: CryptoKey;

    constructor(name: string) {
        this.name = name;
    }

    // Tạo cặp khóa ECDH (Elliptic-curve Diffie-Hellman)
    async generateKeys(): Promise<void> {
        const keyPair = await crypto.subtle.generateKey(
            { name: "ECDH", namedCurve: "P-256" },
            true, // Key có thể được export
            ["deriveKey"] // Mục đích của key là để tạo ra key khác
        );
        this.privateKey = keyPair.privateKey;
        this.publicKey = keyPair.publicKey;
        console.log(`[${this.name}] Đã tạo cặp key.`);
    }

    // Mã hóa tin nhắn cho người nhận
    async encryptMessage(recipientPublicKey: CryptoKey, message: string): Promise<{ iv: Uint8Array, encryptedData: ArrayBuffer }> {
        // Tạo shared secret từ private key của mình và public key của người nhận
        const sharedSecret = await this.deriveSharedSecret(recipientPublicKey);

        // Sử dụng shared secret làm khóa cho mã hóa đối xứng AES-GCM
        const iv = crypto.getRandomValues(new Uint8Array(12)); // Initialization Vector
        const encoder = new TextEncoder();
        const encodedMessage = encoder.encode(message);

        const encryptedData = await crypto.subtle.encrypt(
            { name: "AES-GCM", iv: iv },
            sharedSecret,
            encodedMessage
        );

        console.log(`[${this.name}] Đã mã hóa tin nhắn.`);
        return { iv, encryptedData };
    }

    // Giải mã tin nhắn nhận được
    async decryptMessage(senderPublicKey: CryptoKey, payload: { iv: Uint8Array, encryptedData: ArrayBuffer }): Promise<string> {
        const sharedSecret = await this.deriveSharedSecret(senderPublicKey);
        
        const decryptedData = await crypto.subtle.decrypt(
            { name: "AES-GCM", iv: payload.iv },
            sharedSecret,
            payload.encryptedData
        );
        
        console.log(`[${this.name}] Đã giải mã tin nhắn.`);
        const decoder = new TextDecoder();
        return decoder.decode(decryptedData);
    }

    // Hàm tiện ích tạo shared secret key
    private async deriveSharedSecret(recipientPublicKey: CryptoKey): Promise<CryptoKey> {
        // Thuật toán ECDH được dùng để tạo ra một shared secret key.
        // Key này sau đó được dùng cho thuật toán AES-GCM.
        return await crypto.subtle.deriveKey(
            { name: "ECDH", public: recipientPublicKey },
            this.privateKey,
            { name: "AES-GCM", length: 256 },
            true,
            ["encrypt", "decrypt"]
        );
    }
}


// --- STEP 2: Mô phỏng kịch bản ---

async function main() {
    console.log("--- Bắt đầu kịch bản E2EE ---");

    // 1. Khởi tạo hai đối tượng User
    const alice = new User("Alice");
    const bob = new User("Bob");

    // 2. Mỗi user tự tạo key pair
    await alice.generateKeys();
    await bob.generateKeys();

    // 3. Trao đổi Public Key (giả định quá trình này diễn ra qua server)
    // Đây là bước có thể bị tấn công MITM nếu thiếu cơ chế xác thực.
    const aliceKnowsBobsPublicKey = bob.publicKey;
    const bobKnowsAlicesPublicKey = alice.publicKey;
    console.log("
[Hệ thống] Alice và Bob đã trao đổi Public Key.");

    // 4. Alice chuẩn bị gửi tin nhắn
    const secretMessage = "E2EE technical implementation.";
    console.log(`
[Alice] Chuẩn bị gửi: "${secretMessage}"`);

    // 5. Alice mã hóa tin nhắn với Public Key của Bob
    const encryptedPayload = await alice.encryptMessage(aliceKnowsBobsPublicKey, secretMessage);
    console.log("[Hệ thống] Alice gửi payload đã được mã hóa cho Bob.");
    // Trong ứng dụng thực tế, payload này sẽ được gửi qua server.

    // 6. Bob nhận payload và giải mã với Public Key của Alice
    const decryptedMessage = await bob.decryptMessage(bobKnowsAlicesPublicKey, encryptedPayload);

    // 7. Xác thực kết quả
    console.log(`
[Bob] Đã nhận và giải mã tin nhắn: "${decryptedMessage}"`);
    console.log("
--- Kết thúc kịch bản ---");
    
    if (secretMessage === decryptedMessage) {
        console.log("✅ Thành công: Tin nhắn gốc và tin nhắn giải mã trùng khớp.");
    } else {
        console.log("❌ Thất bại: Có lỗi xảy ra trong quá trình E2EE.");
    }
}

main();

Đoạn code trên chỉ là một minh họa tối giản về mặt khái niệm. Một hệ thống E2EE thực tế sẽ yêu cầu xử lý các vấn đề phức tạp hơn:

  • Quản lý và lưu trữ Key: PrivateKey phải được lưu trữ an toàn trên thiết bị của người dùng, không bao giờ được rời khỏi thiết bị.
  • Perfect Forward Secrecy (PFS): Sử dụng các ephemeral key (khóa dùng một lần) cho mỗi phiên giao tiếp. Điều này đảm bảo nếu một private key dài hạn bị lộ, các tin nhắn trong quá khứ vẫn được bảo vệ.
  • Mã hóa cho Group Chat: Việc triển khai E2EE cho nhóm nhiều người dùng phức tạp hơn đáng kể so với giao tiếp 1-1.
  • Xây dựng giao diện xác thực: Cần có UI/UX để người dùng có thể dễ dàng so sánh Safety Number.

5. Kết luận

Việc triển khai E2EE không chỉ là một bài toán về mã hóa. Nó đòi hỏi một protocolquản lý key chặt chẽ, một kiến trúc hệ thống an toàn, và một thiết kế sản phẩm thông minh để người dùng có thể tham gia vào quá trình xác thực.

Để xây dựng ứng dụng thực tế, các bạn nên nghiên cứu và sử dụng các thư viện đã được kiểm chứng nhưlibsodium-wrappers hoặc các thư viện triển khai sẵn Signal Protocol. Chúc các bạn thành công

Bài viết liên quan

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab