2025-02-02
密码学听起来很硬,但写业务代码时遇到的场景往往很朴素:生成登录 token、保存用户密码、给配置文件加密、校验下载包有没有被改、给接口消息签名。
真正危险的地方也不在“语法难”,而在“看起来能跑,其实不安全”。密码学代码最忌讳自己拍脑袋设计规则。该用成熟库就用成熟库,该用标准算法就用标准算法,不要发明“位移几下再 base64”的土办法。
这篇用 Python 把常见场景捋清楚:随机数、哈希、密码存储、对称加密、数字签名、密钥管理。目标不是变成密码学专家,而是写出不离谱的工程代码。
很多坑来自概念混用。
编码:把数据换一种表示方式,比如 base64,不提供保密
哈希:把内容变成摘要,适合完整性校验,不能反向还原
密码存储:给用户密码做慢哈希,防止泄露后被快速撞库
加密:把明文变成密文,需要密钥才能还原
签名:证明内容来自某个私钥,并且内容没有被改
如果你要保存用户密码,不是“加密后存起来”,而是用专门的密码哈希算法。
如果你要传输敏感内容,不是“base64 一下”,而是用带认证的加密方案。
如果你要证明文件没被篡改,哈希能帮你检查内容;如果还要证明是谁发布的,就需要签名。
业务里经常要生成 token、临时口令、salt、密钥材料。这里不要用 random,它适合模拟和抽样,不适合安全用途。Python 官方提供了 secrets,就是为这类场景准备的。
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。
做用户密码存储,可以使用 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、参数、摘要。库已经把需要的信息编码好了。
如果你的需求是“把一段配置、令牌、私密字段加密保存”,可以先考虑 PyCA cryptography 里的 Fernet。它封装了加密和完整性校验,使用上不容易踩太多坑。
pip install cryptography
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 适合很多中小型业务场景。但它不是文件加密系统,也不是万能方案。如果要处理超大文件、流式加密、协议层数据,需要更仔细的设计。
如果你需要明确控制随机 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 使用起来比较清爽:
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())
签名不会隐藏内容。如果消息本身是敏感的,仍然要考虑加密。签名更像盖章:别人能看内容,但能确认它有没有被改、是不是对应私钥签出来的。
很多安全事故不是算法坏了,而是密钥乱放。
不要把密钥提交到仓库
不要把生产密钥写进镜像
不要把同一个密钥到处复用
不要把密钥打印到日志
给不同用途使用不同密钥
密钥需要有替换方案
本地开发可以从环境变量读取:
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(),避免普通字符串比较暴露额外细节。共享密钥也要像其他密钥一样管理,不能直接写死在代码仓库里。