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 的核心思想——为什么「分解大数」这么难,以及公钥和私钥是怎么来的。