网络安全——Python与密码学




2025-02-02

blog_main_img

密码学听起来很硬,但写业务代码时遇到的场景往往很朴素:生成登录 token、保存用户密码、给配置文件加密、校验下载包有没有被改、给接口消息签名。

真正危险的地方也不在“语法难”,而在“看起来能跑,其实不安全”。密码学代码最忌讳自己拍脑袋设计规则。该用成熟库就用成熟库,该用标准算法就用标准算法,不要发明“位移几下再 base64”的土办法。

这篇用 Python 把常见场景捋清楚:随机数、哈希、密码存储、对称加密、数字签名、密钥管理。目标不是变成密码学专家,而是写出不离谱的工程代码。

先分清几件事

很多坑来自概念混用。

编码:把数据换一种表示方式,比如 base64,不提供保密
哈希:把内容变成摘要,适合完整性校验,不能反向还原
密码存储:给用户密码做慢哈希,防止泄露后被快速撞库
加密:把明文变成密文,需要密钥才能还原
签名:证明内容来自某个私钥,并且内容没有被改

如果你要保存用户密码,不是“加密后存起来”,而是用专门的密码哈希算法。

如果你要传输敏感内容,不是“base64 一下”,而是用带认证的加密方案。

如果你要证明文件没被篡改,哈希能帮你检查内容;如果还要证明是谁发布的,就需要签名。

安全随机数:别用 random

业务里经常要生成 token、临时口令、salt、密钥材料。这里不要用 random,它适合模拟和抽样,不适合安全用途。Python 官方提供了 secrets,就是为这类场景准备的。

Python 安全随机数

import secrets


def create_api_token():
    return "app_" + secrets.token_urlsafe(32)


def create_reset_code():
    alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
    return "".join(secrets.choice(alphabet) for _ in range(10))


api_token = create_api_token()
reset_code = create_reset_code()

print(api_token)
print(reset_code)

几个小原则:

给用户看的短码要避免容易混淆的字符
服务端 token 要足够长
token 只展示一次,服务端存摘要更稳
密钥不要写死在代码里

哈希:适合校验,不适合直接存密码

hashlib 可以计算 SHA-256 这类摘要。它适合做文件完整性校验、内容指纹、去重标识。

import hashlib
from pathlib import Path


def sha256_file(path):
    digest = hashlib.sha256()
    with Path(path).open("rb") as file:
        for chunk in iter(lambda: file.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


print(sha256_file("package.zip"))

但不要这样存用户密码:

import hashlib


def bad_password_hash(password):
    return hashlib.sha256(password.encode("utf-8")).hexdigest()

这段代码能跑,但不适合密码存储。普通哈希太快,攻击者拿到密码库后可以疯狂尝试。密码存储要用慢哈希,并且每个密码都要有独立 salt。

密码存储:优先考虑 Argon2id

做用户密码存储,可以使用 argon2-cffi。它会帮你处理 salt、参数和验证流程。

pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()


def hash_password(password):
    return ph.hash(password)


def verify_password(stored_hash, password):
    try:
        ok = ph.verify(stored_hash, password)
    except VerifyMismatchError:
        return False

    if ok and ph.check_needs_rehash(stored_hash):
        print("密码参数需要升级,可在用户下次登录后重写哈希。")

    return ok


stored = hash_password("correct horse battery staple")
print(verify_password(stored, "wrong password"))
print(verify_password(stored, "correct horse battery staple"))

这里有个关键点:保存的是 ph.hash() 返回的整串内容,不要自己拆 salt、参数、摘要。库已经把需要的信息编码好了。

对称加密:先用简单稳的 Fernet

如果你的需求是“把一段配置、令牌、私密字段加密保存”,可以先考虑 PyCA cryptography 里的 Fernet。它封装了加密和完整性校验,使用上不容易踩太多坑。

pip install cryptography

Python 对称加密流程

from cryptography.fernet import Fernet


def create_key():
    return Fernet.generate_key()


def encrypt_text(key, text):
    cipher = Fernet(key)
    return cipher.encrypt(text.encode("utf-8"))


def decrypt_text(key, token):
    cipher = Fernet(key)
    return cipher.decrypt(token).decode("utf-8")


key = create_key()
token = encrypt_text(key, "db_password=super-secret")
plain = decrypt_text(key, token)

print(token)
print(plain)

Fernet 适合很多中小型业务场景。但它不是文件加密系统,也不是万能方案。如果要处理超大文件、流式加密、协议层数据,需要更仔细的设计。

更底层一点:AES-GCM

如果你需要明确控制随机 nonce、附加认证数据、二进制结构,可以用 AES-GCM。它属于带认证的加密模式,能同时提供保密性和篡改检测。

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


def encrypt_blob(key, data, aad):
    nonce = os.urandom(12)
    cipher = AESGCM(key)
    encrypted = cipher.encrypt(nonce, data, aad)
    return nonce + encrypted


def decrypt_blob(key, payload, aad):
    nonce = payload[:12]
    encrypted = payload[12:]
    cipher = AESGCM(key)
    return cipher.decrypt(nonce, encrypted, aad)


key = AESGCM.generate_key(bit_length=256)
payload = encrypt_blob(
    key=key,
    data=b"secret payload",
    aad=b"user:42",
)
plain = decrypt_blob(
    key=key,
    payload=payload,
    aad=b"user:42",
)

print(plain)

AES-GCM 的重要规则:同一个 key 下不要重复使用 nonce。你可以把 nonce 和密文一起保存,nonce 不需要保密,但必须保证组合不乱。

数字签名:证明来源和完整性

加密解决“别人看不懂”,签名解决“是不是你发的、内容有没有被改”。比如发布文件、接口回调、离线授权,都可能用到签名。

Ed25519 使用起来比较清爽:

Python 数字签名

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat


private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()

message = b"release artifact digest: abc123"
signature = private_key.sign(message)

try:
    public_key.verify(signature, message)
    print("signature ok")
except InvalidSignature:
    print("signature failed")

public_bytes = public_key.public_bytes(
    encoding=Encoding.Raw,
    format=PublicFormat.Raw,
)

print(public_bytes.hex())

签名不会隐藏内容。如果消息本身是敏感的,仍然要考虑加密。签名更像盖章:别人能看内容,但能确认它有没有被改、是不是对应私钥签出来的。

密钥管理:代码写得再好,key 泄露也白搭

很多安全事故不是算法坏了,而是密钥乱放。

不要把密钥提交到仓库
不要把生产密钥写进镜像
不要把同一个密钥到处复用
不要把密钥打印到日志
给不同用途使用不同密钥
密钥需要有替换方案

本地开发可以从环境变量读取:

import os
from cryptography.fernet import Fernet


def load_fernet():
    key = os.environ["APP_FERNET_KEY"].encode("utf-8")
    return Fernet(key)


cipher = load_fernet()
secret = cipher.encrypt(b"internal config")

print(secret)

生成环境更适合接入密钥管理服务。应用只拿到自己需要的密钥,权限也要尽量收窄。

消息签名:接口回调常用套路

很多 webhook 会给请求体做签名。接收方用共享密钥重新计算签名,再和请求头里的签名比较。

import hmac
import hashlib
import secrets


def sign_body(secret_key, body):
    digest = hmac.new(
        secret_key,
        body,
        hashlib.sha256,
    ).hexdigest()
    return "sha256=" + digest


def verify_body(secret_key, body, header_value):
    expected = sign_body(secret_key, body)
    return secrets.compare_digest(expected, header_value)


secret_key = b"webhook-shared-secret"
body = b'{"event":"paid","order_id":"A1001"}'
signature = sign_body(secret_key, body)

print(verify_body(secret_key, body, signature))

这里使用 secrets.compare_digest(),避免普通字符串比较暴露额外细节。共享密钥也要像其他密钥一样管理,不能直接写死在代码仓库里。