Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

對稱加密在真實系統中的用法

| , 9 minutes reading.

1. 為什麼要關心這個問題?

你已經知道 AES-GCM 是好的選擇,ECB 是災難。但當你面對真實的工程問題時:

  • 「我應該把 IV 存在哪裡?」
  • 「金鑰怎麼產生和儲存?」
  • 「我需要加密多少次?」
  • 「效能會是問題嗎?」

這些問題在教科書裡很少提到,但在生產環境中會決定你的系統是安全的還是脆弱的。

2. HTTPS/TLS 中的對稱加密

為什麼 HTTPS 使用對稱加密

TLS 握手使用非對稱加密來交換金鑰。但一旦握手完成,所有資料傳輸都用對稱加密。為什麼?

非對稱加密(RSA-2048):~1 MB/s
對稱加密(AES-256-GCM):~1 GB/s

相差 1000 倍!

TLS 1.3 的對稱加密階段

┌─────────────────────────────────────────────────────────────┐
│ TLS 1.3 握手後                                              │
├─────────────────────────────────────────────────────────────┤
│ 用戶端 → 伺服器:                                           │
│   Application Data                                          │
│   encrypted with client_application_traffic_secret          │
│   using AES-256-GCM or ChaCha20-Poly1305                    │
├─────────────────────────────────────────────────────────────┤
│ 伺服器 → 用戶端:                                           │
│   Application Data                                          │
│   encrypted with server_application_traffic_secret          │
│   using AES-256-GCM or ChaCha20-Poly1305                    │
└─────────────────────────────────────────────────────────────┘

TLS 的金鑰派生

TLS 不直接使用交換的金鑰。它使用 HKDF(HMAC-based Key Derivation Function)派生多個金鑰:

Master Secret

    ├──► client_handshake_traffic_secret
    ├──► server_handshake_traffic_secret
    ├──► client_application_traffic_secret
    └──► server_application_traffic_secret

每個方向有獨立的金鑰
防止反射攻擊

TLS 的 Nonce 管理

TLS 1.3 使用隱式 nonce:

nonce = static_IV XOR record_sequence_number

record_sequence_number 從 0 開始遞增
每個連線的 static_IV 不同
保證 nonce 不重複

3. 檔案加密的最佳實踐

基本架構

原始檔案


┌─────────────────────────────────────────────┐
│ 1. 產生隨機 DEK(Data Encryption Key)      │
├─────────────────────────────────────────────┤
│ 2. 用 DEK 加密檔案(AES-GCM)               │
├─────────────────────────────────────────────┤
│ 3. 用 KEK(Key Encryption Key)加密 DEK     │
├─────────────────────────────────────────────┤
│ 4. 儲存:加密的 DEK + IV + 加密的檔案       │
└─────────────────────────────────────────────┘

為什麼需要兩層金鑰

只用一個金鑰:
- 換金鑰需要重新加密所有檔案
- 金鑰洩漏 = 所有資料洩漏

兩層金鑰(DEK + KEK):
- 每個檔案有自己的 DEK
- 只需要重新加密 DEK(很小)
- 可以實現金鑰輪替而不動資料

實作範例

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import os
import json
import base64

class FileEncryptor:
    def __init__(self, password: str):
        """從密碼派生 KEK"""
        self.salt = os.urandom(16)
        self.kek = self._derive_kek(password, self.salt)

    def _derive_kek(self, password: str, salt: bytes) -> bytes:
        """使用 scrypt 從密碼派生金鑰"""
        kdf = Scrypt(
            salt=salt,
            length=32,
            n=2**20,  # CPU/記憶體成本
            r=8,
            p=1,
        )
        return kdf.derive(password.encode())

    def encrypt_file(self, plaintext: bytes) -> dict:
        """加密檔案"""
        # 1. 產生隨機 DEK
        dek = AESGCM.generate_key(bit_length=256)

        # 2. 用 DEK 加密資料
        data_nonce = os.urandom(12)
        data_cipher = AESGCM(dek)
        encrypted_data = data_cipher.encrypt(data_nonce, plaintext, None)

        # 3. 用 KEK 加密 DEK
        key_nonce = os.urandom(12)
        key_cipher = AESGCM(self.kek)
        encrypted_dek = key_cipher.encrypt(key_nonce, dek, None)

        # 4. 打包結果
        return {
            'version': 1,
            'salt': base64.b64encode(self.salt).decode(),
            'key_nonce': base64.b64encode(key_nonce).decode(),
            'encrypted_dek': base64.b64encode(encrypted_dek).decode(),
            'data_nonce': base64.b64encode(data_nonce).decode(),
            'encrypted_data': base64.b64encode(encrypted_data).decode(),
        }

    def decrypt_file(self, encrypted: dict) -> bytes:
        """解密檔案"""
        # 解碼
        key_nonce = base64.b64decode(encrypted['key_nonce'])
        encrypted_dek = base64.b64decode(encrypted['encrypted_dek'])
        data_nonce = base64.b64decode(encrypted['data_nonce'])
        encrypted_data = base64.b64decode(encrypted['encrypted_data'])

        # 1. 解密 DEK
        key_cipher = AESGCM(self.kek)
        dek = key_cipher.decrypt(key_nonce, encrypted_dek, None)

        # 2. 解密資料
        data_cipher = AESGCM(dek)
        plaintext = data_cipher.decrypt(data_nonce, encrypted_data, None)

        return plaintext

大檔案處理

對於 GB 級的大檔案,你不能把整個檔案讀入記憶體:

def encrypt_large_file(input_path: str, output_path: str, key: bytes):
    """串流加密大檔案"""
    CHUNK_SIZE = 64 * 1024  # 64KB chunks

    # 使用 AES-GCM-SIV 或 AES-CTR + HMAC
    # 注意:標準 AES-GCM 不適合串流加密,因為需要完整資料計算 tag

    # 更好的選擇:使用專門設計的檔案加密格式
    # 如 age (https://age-encryption.org/)
    pass

建議:對於大檔案,使用成熟的工具如 agegpg,而不是自己實作。

4. 資料庫加密

加密層級

┌─────────────────────────────────────────────────────────────┐
│ 1. 傳輸加密(TLS)                                          │
│    - 加密用戶端和資料庫之間的通訊                           │
│    - 防止網路竊聽                                           │
├─────────────────────────────────────────────────────────────┤
│ 2. 透明資料加密(TDE)                                      │
│    - 資料庫檔案層級加密                                     │
│    - 防止磁碟被盜                                           │
│    - 對應用程式透明                                         │
├─────────────────────────────────────────────────────────────┤
│ 3. 欄位層級加密                                             │
│    - 應用程式層級加密                                       │
│    - 只加密敏感欄位                                         │
│    - 資料庫管理員也看不到明文                               │
└─────────────────────────────────────────────────────────────┘

欄位層級加密範例

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
import base64

class EncryptedField:
    def __init__(self, key: bytes):
        self.cipher = AESGCM(key)

    def encrypt(self, value: str) -> str:
        """加密欄位值"""
        nonce = os.urandom(12)
        ciphertext = self.cipher.encrypt(nonce, value.encode(), None)
        # 格式:nonce + ciphertext,base64 編碼
        return base64.b64encode(nonce + ciphertext).decode()

    def decrypt(self, encrypted_value: str) -> str:
        """解密欄位值"""
        data = base64.b64decode(encrypted_value)
        nonce = data[:12]
        ciphertext = data[12:]
        plaintext = self.cipher.decrypt(nonce, ciphertext, None)
        return plaintext.decode()

# 使用
field_key = os.urandom(32)
encrypted_field = EncryptedField(field_key)

# 儲存到資料庫
ssn = "123-45-6789"
encrypted_ssn = encrypted_field.encrypt(ssn)
# INSERT INTO users (encrypted_ssn) VALUES ('...')

# 從資料庫讀取
decrypted_ssn = encrypted_field.decrypt(encrypted_ssn)

加密欄位的查詢問題

-- 這不能工作!
SELECT * FROM users WHERE encrypted_ssn = ?

-- 因為相同的明文會產生不同的密文(不同的 nonce)

解決方案:

1. 盲索引(Blind Index)
   - 對明文計算 HMAC
   - 儲存 HMAC 作為可搜尋的索引
   - 查詢時計算 HMAC 進行比對

2. 確定性加密(Deterministic Encryption)
   - 固定 nonce 或使用 SIV 模式
   - 相同明文產生相同密文
   - 可以精確匹配
   - 會洩漏相等性資訊

3. 同態加密(Homomorphic Encryption)
   - 可以在密文上進行運算
   - 效能開銷很大
   - 仍在研究階段

盲索引實作

import hmac
import hashlib

def create_blind_index(value: str, key: bytes) -> str:
    """建立可搜尋的盲索引"""
    h = hmac.new(key, value.encode(), hashlib.sha256)
    # 只取前 16 位元組,減少儲存空間,增加一點模糊性
    return base64.b64encode(h.digest()[:16]).decode()

# 使用
index_key = os.urandom(32)  # 與加密金鑰不同!

ssn = "123-45-6789"
ssn_index = create_blind_index(ssn, index_key)

# 儲存
# INSERT INTO users (encrypted_ssn, ssn_index) VALUES (?, ?)

# 查詢
search_index = create_blind_index("123-45-6789", index_key)
# SELECT * FROM users WHERE ssn_index = ?

5. 金鑰管理

金鑰的生命週期

生成 → 分發 → 使用 → 輪替 → 撤銷 → 銷毀

每個階段都有安全考量:
- 生成:必須使用 CSPRNG
- 分發:必須安全傳輸
- 使用:必須限制存取
- 輪替:必須支援多版本
- 撤銷:必須快速生效
- 銷毀:必須不可恢復

金鑰儲存選項

┌─────────────────────────────────────────────────────────────┐
│ 開發環境                                                    │
│ - 環境變數                                                  │
│ - 設定檔(不要提交到 Git!)                                │
├─────────────────────────────────────────────────────────────┤
│ 生產環境                                                    │
│ - 雲端金鑰管理服務(AWS KMS、GCP KMS、Azure Key Vault)     │
│ - HashiCorp Vault                                           │
│ - 硬體安全模組(HSM)                                       │
└─────────────────────────────────────────────────────────────┘

使用雲端 KMS 的範例

import boto3

class KMSKeyManager:
    def __init__(self, key_id: str):
        self.kms = boto3.client('kms')
        self.key_id = key_id

    def generate_data_key(self) -> tuple:
        """產生資料金鑰"""
        response = self.kms.generate_data_key(
            KeyId=self.key_id,
            KeySpec='AES_256'
        )
        return (
            response['Plaintext'],      # 用於加密
            response['CiphertextBlob']  # 儲存這個
        )

    def decrypt_data_key(self, encrypted_key: bytes) -> bytes:
        """解密資料金鑰"""
        response = self.kms.decrypt(
            KeyId=self.key_id,
            CiphertextBlob=encrypted_key
        )
        return response['Plaintext']

# 使用
km = KMSKeyManager('alias/my-key')

# 加密時
plaintext_key, encrypted_key = km.generate_data_key()
# 用 plaintext_key 加密資料
# 儲存 encrypted_key 和加密的資料

# 解密時
plaintext_key = km.decrypt_data_key(encrypted_key)
# 用 plaintext_key 解密資料

6. 效能考量

硬體加速

# 檢查 CPU 是否支援 AES-NI
import subprocess
result = subprocess.run(['grep', 'aes', '/proc/cpuinfo'], capture_output=True)
has_aesni = b'aes' in result.stdout

# 現代 CPU 幾乎都支援
# AES-NI 可以讓 AES 加密快 10 倍以上

加密對效能的影響

操作              | 沒有加密    | AES-256-GCM
-------------------------------------------
檔案讀寫          | 1.0x       | ~1.1x
網路傳輸          | 1.0x       | ~1.05x
資料庫查詢        | 1.0x       | 1.0x(TDE)
欄位加密/解密     | 1.0x       | ~1.5-2x

結論:對於大多數應用,加密的效能開銷可以忽略

何時效能會是問題

1. 大量小檔案
   - 每次加密都需要初始化
   - 考慮批次處理

2. 即時資料串流
   - 延遲敏感
   - 考慮 ChaCha20-Poly1305(沒有 AES-NI 時更快)

3. 資料庫欄位加密 + 大量查詢
   - 每次存取都需要加解密
   - 考慮快取解密後的值

7. 常見錯誤總結

錯誤後果正確做法
把金鑰硬編碼在程式碼中金鑰隨程式碼外洩使用環境變數或金鑰管理服務
使用密碼直接當金鑰金鑰空間太小使用 KDF(PBKDF2、scrypt、Argon2)
不儲存 IV/nonce無法解密IV 可以和密文一起儲存
把 IV 存成祕密沒必要,反而增加複雜性IV 不需要保密,只需要唯一
用同一個金鑰加密太多資料GCM 有資料量限制定期輪替金鑰
自己實作加密邏輯幾乎肯定有漏洞使用成熟的函式庫

8. 本章小結

三點要記住:

  1. HTTPS 展示了對稱加密的最佳實踐。 金鑰派生、nonce 管理、認證加密——TLS 的設計值得學習。

  2. 檔案加密用雙層金鑰(DEK + KEK)。 這讓金鑰輪替變得簡單,也提供了更好的安全隔離。

  3. 資料庫加密有多個層級。 傳輸加密、透明資料加密、欄位加密各有用途。欄位加密要考慮查詢的問題。

9. 下一步

我們已經完成了對稱加密的深度探索。但對稱加密有一個根本問題:雙方需要預先共享金鑰。

在下一部分,我們將進入非對稱加密的世界:RSA 的核心思想——為什麼「分解大數」這麼難,以及公鑰和私鑰是怎麼來的。