Python 基础体系 · 第 61/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 哈希、HMAC 与 TLS:完整性、密码存储、证书和误区

在 Python 安全编程中,hashlibhmacsecretsssl 经常同时出现,但它们解决的是不同问题:

  • 哈希:把任意长度输入映射为固定长度摘要,主要用于完整性校验、内容寻址和指纹。
  • HMAC:使用共享密钥计算消息认证码,解决“消息是否被篡改,以及是否由掌握密钥的一方生成”。
  • 密码存储:不是简单调用哈希,而是使用带盐、可调成本的密码派生函数。
  • TLS:在网络连接层提供加密、完整性和对端认证;证书用于认证身份,不等于“证书本身加密了数据”。
  • secrets:从操作系统获得适合安全用途的随机数,用来生成盐、令牌和密钥,而不是使用面向模拟的 random

这些概念的共同底层是密码学,但安全目标、密钥状态、攻击模型和生命周期都不同。把一个工具替代另一个工具,通常会导致“代码看起来用了密码学,实际却没有获得相应安全性”。


一、先区分四种安全目标

1. 完整性:数据有没有被修改

完整性关注的是:

接收方能否发现消息内容在传输或存储过程中发生了变化?

例如,下载文件后重新计算 SHA-256 摘要:

from pathlib import Path
import hashlib

path = Path("example.bin")

digest = hashlib.file_digest(path.open("rb"), "sha256").hexdigest()
print(digest)

hashlib.file_digest() 会读取文件并返回摘要对象。Python 3.14 中,如果文件以非阻塞模式打开,可能抛出 BlockingIOError;调用后文件对象也应被视为处于未知状态,因此示例中由 open() 创建的文件对象应及时关闭。(docs.python.org)

更稳妥的写法是显式管理文件生命周期:

import hashlib

with open("example.bin", "rb") as f:
    digest = hashlib.file_digest(f, "sha256").hexdigest()

print(digest)

但是,普通哈希只能回答:

当前计算出的摘要是否等于某个已知摘要?

它不能回答:

这个已知摘要是不是攻击者自己伪造的?

攻击者可以同时替换文件和摘要:

原始文件      -> example.bin
原始摘要      -> example.bin.sha256

攻击者替换为:

恶意文件      -> example.bin
恶意文件摘要  -> example.bin.sha256

因此,普通哈希适合检测偶然损坏、文件去重、缓存键和内容指纹,但不能单独抵抗主动攻击者。


2. 认证:消息是否来自掌握密钥的一方

认证关注的是:

消息不仅没有被修改,而且是否由拥有某个秘密的实体生成?

这需要密钥。HMAC 就是这种机制。

3. 机密性:窃听者能不能看懂内容

机密性需要加密。哈希和 HMAC 都不是加密:

  • 哈希没有解密操作;
  • HMAC 没有解密操作;
  • 两者都不隐藏原始消息。

TLS 则同时使用加密和认证机制,保护网络连接中的数据。

4. 身份认证:对方到底是谁

即使连接被加密,也还需要确认:

我连接的服务器真的是目标服务器,而不是中间人?

TLS 通过证书链、主机名校验和受信任 CA 来解决这个问题。证书是身份绑定材料,不是简单的“加密文件”。


二、哈希函数:输入、摘要和安全性质

1. 哈希的形式化模型

设哈希函数为:

H:{0,1}{0,1}nH:\{0,1\}^{*}\rightarrow\{0,1\}^{n}

其中:

  • {0,1}\{0,1\}^{*}:任意长度的比特串;
  • {0,1}n\{0,1\}^{n}:长度固定为 nn 位的摘要空间;
  • H(m)H(m):消息 mm 的摘要。

以 SHA-256 为例,输入可以是任意长度,但输出始终是 256 位,也就是 32 字节,十六进制表示通常是 64 个字符。

import hashlib

data = b"hello"

raw = hashlib.sha256(data).digest()
text = hashlib.sha256(data).hexdigest()

print(len(raw))   # 32
print(len(text))  # 64
print(text)

digest() 返回原始二进制摘要;hexdigest() 只是把每个字节转换成两个十六进制字符,便于日志、文本协议和命令行传输。十六进制字符串并没有比原始摘要“更安全”,只是表示形式不同。


2. 哈希对象是增量状态机

哈希计算可以分块进行:

import hashlib

h1 = hashlib.sha256()
h1.update(b"hello")
h1.update(b" ")
h1.update(b"world")

h2 = hashlib.sha256(b"hello world")

assert h1.digest() == h2.digest()

哈希对象内部维护一个状态。连续调用:

h.update(a)
h.update(b)

等价于对拼接结果计算:

hash(a + b)

这里的“拼接”是字节拼接,不是字符串语义拼接。以下两个输入不同:

b"ab" + b"c"  # b"abc"
b"a" + b"bc"  # b"abc"

而下面两个逻辑对象可能产生同样的序列化结果:

["ab", "c"]
["a", "bc"]

如果业务需要对结构化数据做签名或认证,就必须定义明确的序列化规则,例如长度前缀、固定字段顺序或规范化 JSON。不能直接依赖“看起来一样”的对象表示。


3. 哈希的三个主要安全性质

原像抗性

已知摘要 dd,难以找到消息 mm,使得:

H(m)=dH(m)=d

这对应“从摘要反推输入”。

第二原像抗性

已知消息 m1m_1,难以找到不同消息 m2m_2,使得:

H(m1)=H(m2)H(m_1)=H(m_2)

碰撞抗性

难以找到任意两个不同消息 m1,m2m_1,m_2,满足:

H(m1)=H(m2)H(m_1)=H(m_2)

普通哈希函数的输出空间有限,而输入空间无限,因此数学上必然存在碰撞。密码学要求的是:攻击者在实际计算资源内难以找到有用碰撞。

Python 文档明确指出,MD5 和 SHA-1 存在已知碰撞弱点;安全场景通常应选择 SHA-2、SHA-3 或 BLAKE2 等现代算法。hashlib 中的具体可用算法还可能受 OpenSSL 构建影响。(docs.python.org)


4. usedforsecurity 不会让弱算法变强

部分哈希构造器支持:

import hashlib

digest = hashlib.md5(b"legacy data", usedforsecurity=False).hexdigest()

usedforsecurity=False 的用途是告诉受限环境:

这个哈希只用于非安全用途,例如兼容旧文件格式或非密码学校验。

它不会修复 MD5 或 SHA-1 的碰撞问题,也不应该被用来绕过安全策略后继续执行密码学认证。


三、HMAC:带共享密钥的消息认证

1. HMAC 解决什么问题

HMAC 的基本形式是:

HMACH(K,m)=H((Kopad)H((Kipad)m))\operatorname{HMAC}_H(K,m) = H\left((K'\oplus opad)\parallel H((K'\oplus ipad)\parallel m)\right)

其中:

  • HH:底层哈希函数;
  • KK:秘密密钥;
  • KK':经过长度调整后的密钥;
  • mm:消息;
  • ipadipadopadopad:固定的内外填充值;
  • \parallel:拼接;
  • \oplus:按位异或。

直觉上,HMAC 对消息做了两层带密钥的哈希:

  1. 内层使用密钥和消息计算摘要;
  2. 外层再次使用密钥包裹内层摘要。

接收方只有在知道同一个密钥 KK 时,才能计算出相同的认证码。

import hashlib
import hmac

key = b"server-client-shared-secret"
message = b"amount=100&currency=CNY"

tag = hmac.digest(key, message, "sha256")
print(tag.hex())

received_tag = tag
assert hmac.compare_digest(tag, received_tag)

hmac.digest() 适合消息已经在内存中的场景;对于流式数据,应使用 HMAC 对象持续调用 update()。该模块实现 RFC 2104,并要求底层摘要具有固定长度,因此 SHAKE-128 和 SHAKE-256 这类可变输出哈希不能用于 HMAC。(docs.python.org)


2. 为什么不能使用普通哈希模拟 HMAC

下面这种写法不安全:

hashlib.sha256(key + message).digest()

它的问题不在于“少写了几行代码”,而在于普通哈希的消息组合方式没有经过 HMAC 的结构化设计。对于某些基于 Merkle–Damgård 构造的哈希函数,简单拼接还会涉及长度扩展攻击等问题。

HMAC 的设计目标就是把底层哈希函数放进经过分析的密钥结构中。工程代码应直接使用 hmac.new()hmac.digest(),而不是自定义“加密哈希”。


3. 验证时使用 compare_digest

错误示例:

if expected_tag == received_tag:
    accept()

普通 == 可能在发现第一个不同字节时提前返回。远程攻击者如果能够重复测量响应时间,理论上可能利用这种差异推断秘密值的前缀。

正确写法:

if not hmac.compare_digest(expected_tag, received_tag):
    raise ValueError("invalid message authentication code")

compare_digest() 要求两边都是同类对象:都为 ASCII 字符串,或都为 bytes-like 对象。长度不同和类型错误仍可能暴露长度或类型信息,但不会直接暴露比较值本身。Python 3.10 及之后,在可用时会使用 OpenSSL 的 CRYPTO_memcmp()。(docs.python.org)


4. HMAC 不提供防重放

假设客户端发送:

message = transfer=100
tag = HMAC(key, message)

攻击者虽然不能修改 transfer=100 而保持合法标签,但可以完整复制这对数据,再发送一次。服务器仍会验证通过。

因此,防重放需要把以下信息纳入认证范围:

  • 单调递增序列号;
  • 时间戳和有效期;
  • 一次性随机数;
  • 请求 ID,并在服务端记录已使用的 ID。

例如:

import hashlib
import hmac

key = b"shared-key"
request_id = b"9b1deb4d-7b7e-4b2f-a6b3-3f0e4d5a2b11"
body = b"amount=100"

signed = request_id + b"\x00" + body
tag = hmac.digest(key, signed, hashlib.sha256)

这里的 \x00 是明确的字段分隔符。生产协议应进一步定义字段编码,避免不同字段组合后产生歧义。


四、密码存储:哈希、HMAC 和密码派生不能混为一谈

1. 密码不是普通数据

数据库密码泄露后,攻击者通常会离线尝试大量候选密码:

guess_1 -> 计算 -> 与数据库值比较
guess_2 -> 计算 -> 与数据库值比较
guess_3 -> 计算 -> 与数据库值比较

因此,密码存储算法的目标不是“让服务器快速验证”,而是:

即使数据库泄露,也让攻击者验证每个候选密码尽可能昂贵。

普通 SHA-256 的优点——快速——在密码存储中反而是缺点。

错误示例:

import hashlib

stored = hashlib.sha256(password.encode()).hexdigest()

即使加盐,单次 SHA-256 仍然太快:

stored = hashlib.sha256(salt + password.encode()).digest()

加盐可以阻止预计算彩虹表和相同密码产生相同数据库值,但不能把快速哈希变成适合密码存储的慢算法。


2. 盐、密钥和密码的区别

盐是每个账户独立生成的随机公开值:

salt = random bytes
stored = KDF(password, salt, cost)

盐不需要保密,可以和派生结果一起存储。它的作用是让相同密码在不同账户上产生不同结果,并阻止攻击者复用预计算结果。

密钥

密钥用于 HMAC 或加密,必须保密。密钥泄露通常意味着攻击者可以伪造认证码或解密数据。

密码

密码通常由人选择,熵低且可能重复使用。因此需要密码派生函数进行“加盐”和“拉伸”。


3. 使用 scrypt

Python 3.14 的 hashlib 提供 scrypt()。它同时增加 CPU 和内存成本,参数分别为:

  • n:CPU/内存成本因子,通常要求为 2 的幂;
  • r:块大小;
  • p:并行化参数;
  • maxmem:内存上限;
  • dklen:派生结果长度。
import base64
import hashlib
import secrets

def hash_password(password: str) -> str:
    if not isinstance(password, str):
        raise TypeError("password must be str")

    password_bytes = password.encode("utf-8")
    salt = secrets.token_bytes(16)

    derived = hashlib.scrypt(
        password_bytes,
        salt=salt,
        n=2**14,
        r=8,
        p=1,
        dklen=32,
    )

    # 格式中保存算法和参数,便于未来升级
    return (
        "scrypt$"
        "n=16384,r=8,p=1$"
        f"{base64.b64encode(salt).decode('ascii')}$"
        f"{base64.b64encode(derived).decode('ascii')}"
    )


def verify_password(password: str, encoded: str) -> bool:
    try:
        algorithm, parameters, salt_text, derived_text = encoded.split("$")
        if algorithm != "scrypt":
            return False

        values = dict(item.split("=") for item in parameters.split(","))
        n = int(values["n"])
        r = int(values["r"])
        p = int(values["p"])

        salt = base64.b64decode(salt_text, validate=True)
        expected = base64.b64decode(derived_text, validate=True)

        actual = hashlib.scrypt(
            password.encode("utf-8"),
            salt=salt,
            n=n,
            r=r,
            p=p,
            dklen=len(expected),
        )
        return hmac.compare_digest(actual, expected)
    except (ValueError, KeyError, TypeError):
        return False

调用:

record = hash_password("correct horse battery staple")

print(record)
print(verify_password("correct horse battery staple", record))  # True
print(verify_password("wrong password", record))                # False

hashlib.scrypt()salt 应来自合适的随机源,Python 文档建议盐至少约 16 字节;nrpmaxmem 会影响 CPU、内存和并发容量,不能直接照搬示例参数到所有生产环境。应在目标机器上测量单次登录验证成本,并设置合理的登录限速。(docs.python.org)

示例保存了参数,是因为未来可能需要提高成本。如果只保存一个没有算法和参数信息的摘要,升级策略会变得困难。


4. 使用 PBKDF2-HMAC

hashlib.pbkdf2_hmac() 使用 HMAC 作为伪随机函数,适用于需要 PBKDF2 的协议或兼容已有系统的场景:

import hashlib
import hmac
import secrets

password = b"correct horse battery staple"
salt = secrets.token_bytes(16)
iterations = 500_000

derived = hashlib.pbkdf2_hmac(
    "sha256",
    password,
    salt,
    iterations,
    dklen=32,
)

candidate = hashlib.pbkdf2_hmac(
    "sha256",
    password,
    salt,
    iterations,
    dklen=32,
)

assert hmac.compare_digest(derived, candidate)

这里的关键关系是:

密码
  + 唯一随机盐
  + 足够高且可升级的迭代次数
  -> PBKDF2-HMAC
  -> 存储派生结果

Python 文档将 pbkdf2_hmac()scrypt()归入密码密钥派生用途,并明确说明 sha1(password) 这样的简单算法无法抵抗暴力破解。PBKDF2 的迭代次数必须根据实际硬件和服务负载测量,而不是把示例中的数值当成永久标准。(docs.python.org)


5. 为什么 HMAC 不是密码存储方案

如果服务端直接保存:

hmac.digest(server_secret, password.encode(), "sha256")

这属于“使用固定秘密对密码做认证码”,仍然不适合作为一般密码存储方案:

  1. 所有用户可能共享同一个服务器密钥;
  2. 密钥一旦泄露,所有密码记录的保护方式同时失效;
  3. HMAC 的设计目标是消息认证,不是专门抵抗离线密码猜测;
  4. 服务器验证密码时仍缺乏独立的、可调节的密码工作因子。

如果业务需求是验证 API 密钥,通常可以把 API 密钥当作高熵随机令牌,并保存其哈希或派生结果;如果是人类密码,则应使用专门的密码哈希方案。


五、secrets:随机性是密码学系统的输入

1. randomsecrets 的边界

random 面向模拟、测试和一般程序逻辑。它的伪随机序列如果种子和状态被推断,后续输出可能被预测。

secrets 使用操作系统提供的安全随机源,适用于密码、认证令牌和其他秘密数据。官方文档明确建议,在安全场景中优先使用 secrets,而不是面向建模和模拟的 random。(docs.python.org)

import secrets

salt = secrets.token_bytes(16)
reset_token = secrets.token_urlsafe(32)
api_key = secrets.token_hex(32)

print(len(salt))         # 16
print(len(api_key))      # 64 个十六进制字符
print(reset_token)

token_hex(32) 表示 32 字节随机数据,编码后通常是 64 个十六进制字符;token_urlsafe(32) 使用 URL 安全的 Base64 表示,文本长度不是固定的 64。文档将 32 字节随机性作为典型场景的参考量,但令牌有效期、撤销机制、泄露渠道和访问频率同样重要。(docs.python.org)


2. 随机盐和随机令牌不是一回事

盐可以公开保存,令牌通常必须保密:

密码盐:
  可以放入数据库
  只要求不可预测、每个账户独立

密码重置令牌:
  发送给用户
  需要不可预测、短时有效、使用后失效

HMAC 密钥:
  不应出现在数据库普通字段、日志或响应中
  用于持续认证

不要因为它们都由 secrets.token_bytes() 生成,就认为它们具有相同的生命周期和存储策略。


六、TLS:网络连接中的加密、完整性和身份认证

1. TLS 的组件

一个典型 TLS 客户端连接包含:

sequenceDiagram
    participant C as Python 客户端
    participant S as 服务器
    participant CA as 受信任 CA 集合

    C->>S: TCP 连接
    C->>S: ClientHello(支持的 TLS 版本、密码套件等)
    S->>C: ServerHello + 证书链
    C->>CA: 验证证书链
    C->>C: 校验主机名、有效期和用途
    C->>S: 完成密钥协商
    C->>S: 加密的应用数据
    S->>C: 加密的应用数据

TLS 握手大致完成三件事:

  1. 协商协议版本和密码套件;
  2. 通过证书认证服务器身份;
  3. 协商会话密钥,随后使用对称加密保护应用数据。

证书本身通常包含公钥、主体名称、有效期、签发者和扩展字段。服务器使用与证书公钥对应的私钥证明自己拥有该身份。真正大量加密应用数据时,通常使用握手阶段协商出的对称会话密钥,因为对称加密更适合高吞吐数据传输。


2. Python 中创建正确的客户端 TLS 上下文

import socket
import ssl

hostname = "www.python.org"
port = 443

context = ssl.create_default_context()

with socket.create_connection((hostname, port), timeout=10) as raw_socket:
    with context.wrap_socket(raw_socket, server_hostname=hostname) as tls_socket:
        print(tls_socket.version())
        print(tls_socket.cipher())
        print(tls_socket.getpeercert()["subject"])

        request = (
            f"GET / HTTP/1.1\r\n"
            f"Host: {hostname}\r\n"
            f"Connection: close\r\n"
            f"\r\n"
        ).encode("ascii")

        tls_socket.sendall(request)
        response = b""

        while True:
            chunk = tls_socket.recv(4096)
            if not chunk:
                break
            response += chunk

        print(response[:200])

关键点不是 wrap_socket() 这一个调用,而是三个参数和状态:

  • ssl.create_default_context() 创建面向常见用途的安全上下文;
  • server_hostname=hostname 提供主机名,用于 SNI 和主机名校验;
  • 连接只有在 TLS 握手和证书校验成功后,才进入应用数据阶段。

create_default_context() 会根据用途设置较高安全等级;以服务器认证为目的时,会启用证书验证并加载指定或系统默认的 CA 证书。(docs.python.org)


3. 不要关闭证书验证来“解决问题”

危险代码:

context = ssl._create_unverified_context()

或者:

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

这样做可能让连接“成功”,但攻击者可以伪造服务器,读取或修改通信内容。常见失败表现是:

ssl.SSLCertVerificationError
ssl.SSLError

正确的诊断顺序应是:

  1. 确认访问的主机名与证书中的 SAN 是否匹配;
  2. 确认系统时间正确;
  3. 确认客户端拥有正确的 CA 根证书;
  4. 检查证书是否过期、尚未生效或链不完整;
  5. 检查代理、企业 TLS 检查设备和本地信任库;
  6. 只有在确认信任模型后,才决定是否使用自定义 CA。

如果是内部服务,应向上下文加载内部 CA,而不是关闭验证:

import ssl

context = ssl.create_default_context(
    purpose=ssl.Purpose.SERVER_AUTH,
    cafile="/etc/my-company/internal-ca.pem",
)

4. 服务器端 TLS 上下文

服务器至少需要证书链和私钥:

import socket
import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(
    certfile="server-chain.pem",
    keyfile="server-key.pem",
)

with socket.socket() as listener:
    listener.bind(("127.0.0.1", 8443))
    listener.listen(5)

    with context.wrap_socket(listener, server_side=True) as tls_listener:
        tls_socket, address = tls_listener.accept()
        with tls_socket:
            data = tls_socket.recv(4096)
            tls_socket.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")

服务器证书文件往往需要包含服务器证书及中间证书链;私钥必须严格保护。监听套接字被包装后,接受到的连接会执行 TLS 握手。握手失败时,不能把连接当作普通明文连接继续处理。

在真实服务中,还需要处理:

  • 握手失败;
  • 客户端提前断开;
  • 超时;
  • 半关闭连接;
  • 证书轮换;
  • 多线程、异步事件循环或进程模型中的上下文复用。

SSLContext 通常应在进程启动阶段配置好并复用,而不是每个请求临时创建。具体协议能力还依赖 CPython 构建时使用的 OpenSSL 和平台套接字行为。(docs.python.org)


七、TLS 完整性和应用层 HMAC 不是重复功能

TLS 已经为连接中的应用数据提供完整性保护,因此在一个正常验证的 HTTPS 连接内,再给每个请求增加 HMAC 通常不是为了“让 TLS 更安全”。

两者的信任边界不同:

TLS:
  保护客户端 <-> TLS 终止点
  终止点可能是反向代理、网关或负载均衡器

应用层 HMAC:
  可继续保护服务 <-> 服务之间的消息
  可跨越代理、队列、缓存和持久化边界

例如:

  • 浏览器到 API 网关:TLS 足够保护传输;
  • API 网关把消息放入消息队列:可以对消息做应用层签名或 HMAC;
  • 文件长期存储:可以使用签名或带密钥的完整性校验;
  • 多个不完全互信的服务:应用层认证可以明确消息来源。

TLS 是连接状态协议;HMAC 是消息级原语。TLS 连接结束后,单独保存的 HMAC 仍可用于验证消息,而 TLS 会话密钥通常不会承担这种长期消息认证职责。


八、证书不是公钥指纹,也不是服务器密码

1. 证书链的验证逻辑

客户端收到服务器证书后,大致验证:

  1. 证书是否由受信任 CA 签发;
  2. 签名链能否连接到本地信任根;
  3. 当前时间是否位于有效期内;
  4. 证书用途是否允许服务器认证;
  5. 请求主机名是否匹配证书的 SAN;
  6. 证书是否被撤销或受到其他策略限制。

其中“证书签名有效”只说明:

某个受信任签发者为这份证书背书。

它不自动说明:

当前连接的主机名一定与证书匹配。

所以 server_hostname 很重要。只验证证书链、不验证主机名,仍可能遭遇中间人攻击。


2. 证书固定与自定义 CA

证书固定是把客户端信任范围进一步收窄,例如固定某个公钥或证书指纹。它可能降低错误 CA 签发证书带来的风险,但也会增加证书轮换、备份证书、应急恢复和代理环境兼容成本。

内部系统更常见的方案是:

  • 建立明确的内部 CA;
  • 将内部根 CA 安装到客户端信任库;
  • 使用正常的主机名校验;
  • 管理证书轮换和撤销。

不要把“固定当前叶子证书”当成默认安全升级。它是一种特殊信任模型,必须配套轮换和恢复流程。


九、常见误区与实际失败表现

误区一:Base64 可以加密

import base64

encoded = base64.b64encode(b"secret")
decoded = base64.b64decode(encoded)

Base64 只是编码。任何看到数据的人都能解码。它适合解决二进制数据进入文本协议的问题,不提供机密性、完整性或认证。


误区二:哈希可以解密

哈希通常是单向函数。密码验证不是“解密数据库中的密码”,而是:

输入候选密码
  -> 使用相同盐和参数重新派生
  -> 与数据库中的派生结果比较

因此服务端不应该需要还原用户原始密码。


误区三:盐必须保密

盐的主要作用是独立化每次密码派生,不是作为密钥。盐可以存入数据库;真正必须保密的是 HMAC 密钥、加密密钥和服务端秘密。


误区四:只要用了 HTTPS,就不用验证证书

如果客户端关闭 CERT_REQUIRED 或主机名校验,HTTPS 可能退化为“加密但不认证”的连接。攻击者仍可建立一个自己的 TLS 服务器,让客户端与攻击者完成加密握手。


误区五:TLS 可以防止所有应用层问题

TLS 保护的是连接中的字节流,不能自动防止:

  • SQL 注入;
  • 命令注入;
  • 不安全反序列化;
  • 越权;
  • 错误的业务授权;
  • 日志泄露 Secret;
  • 服务端将恶意输入传给其他系统。

TLS 不能替代输入验证、参数化查询、最小权限和安全依赖管理。


误区六:HMAC 密钥可以放在请求里

如果请求包含:

message
key
HMAC(key, message)

接收方和攻击者都知道密钥,攻击者可以自行修改消息并重新计算 HMAC。HMAC 的前提是验证方和攻击方不能同时获得密钥。


误区七:只比较十六进制字符串就足够

应确保:

  • 编码规则固定;
  • 大小写规则固定;
  • 没有隐式 Unicode 规范化差异;
  • 比较使用 compare_digest()
  • 长度和格式在比较前经过验证。

例如,"é".encode("utf-8") 与某些其他编码的结果不同。密码和消息必须明确规定编码,不能依赖平台默认编码。


十、一个最小的选择模型

可以按安全目标选择工具:

需求 合适工具 是否需要秘密
文件指纹、缓存键 hashlib.sha256()、BLAKE2
检查偶然传输损坏 哈希或校验和
防止有密钥的攻击者篡改消息 hmac 或 BLAKE2 keyed mode
保存人类密码 hashlib.scrypt()pbkdf2_hmac() 否,但需要随机盐
生成密码重置令牌 secrets.token_urlsafe() 令牌本身是秘密
客户端到服务器的传输保护 ssl.create_default_context() 由 TLS 管理
服务器身份认证 TLS 证书链和主机名校验 私钥必须保密

BLAKE2 也支持 keyed mode,可以作为 HMAC 的一种更直接替代,但这不意味着它可以用于密码存储。Python 文档特别提醒,普通哈希或 BLAKE2 的 salted hashing 仍不适合密码哈希。(docs.python.org)


十一、最终检查:每个原语保护的边界

判断一段安全代码是否合理,可以连续追问四个问题:

  1. 攻击者能修改什么?
    如果只能造成随机损坏,普通哈希可能足够;如果攻击者可以主动伪造,就需要认证密钥或数字签名。

  2. 攻击者能看到什么?
    如果消息内容必须保密,需要加密;哈希和 HMAC 都不会隐藏消息。

  3. 验证方如何知道密钥或信任根?
    HMAC 依赖预共享秘密;TLS 依赖证书链、CA 和主机名策略。

  4. 秘密泄露后的影响是什么?
    密码数据库泄露、HMAC 密钥泄露、TLS 私钥泄露和重置令牌泄露,恢复方式完全不同。

可以把完整流程概括为:

安全随机数
  -> 生成盐、令牌或密钥

哈希
  -> 内容指纹和非密钥完整性

密码派生函数
  -> 慢速、带盐地存储人类密码

HMAC
  -> 使用共享密钥认证消息

TLS
  -> 在网络连接中协商密钥、验证身份并保护数据

最重要的不是记住某个 API,而是先明确安全目标,再选择满足该目标的原语。sha256(password)HMAC(password)、Base64、关闭证书校验,看起来都可能“让程序跑起来”,但它们并没有替代密码存储、身份认证或机密性保护。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。