Files
along_django/utils/wechat_v3.py
2026-07-14 14:09:43 +08:00

165 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# utils/wechat_v3.py
"""
微信支付 V3 签名 / 验签 / 回调解密。
支持传入商户配置 dict未传时使用 wechat_mch_service 默认配置表优先settings 兜底)。
"""
import base64
import hashlib
import logging
import os
import time
from urllib.parse import urlparse
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from Crypto.Cipher import AES
logger = logging.getLogger('utils.wechat_v3')
def _load_platform_public_key(pem_bytes):
"""加载微信平台证书或公钥 PEM兼容 CERTIFICATE / PUBLIC KEY / RSA PUBLIC KEY"""
if b'BEGIN CERTIFICATE' in pem_bytes:
return x509.load_pem_x509_certificate(pem_bytes, default_backend()).public_key()
return serialization.load_pem_public_key(pem_bytes, backend=default_backend())
def _resolve_cfg(wx_cfg=None):
if wx_cfg:
return wx_cfg
from utils.wechat_mch_service import get_default_wx_cfg
cfg = get_default_wx_cfg()
if not cfg:
raise RuntimeError('无可用微信商户配置')
return cfg
def load_private_key(wx_cfg=None):
"""加载私钥(支持 PKCS#1 和 PKCS#8"""
cfg = _resolve_cfg(wx_cfg)
key_path = cfg['PRIVATE_KEY_PATH']
with open(key_path, 'rb') as f:
key_data = f.read()
return serialization.load_pem_private_key(
key_data,
password=None,
backend=default_backend(),
)
def build_authorization(method, url, body, wx_cfg=None):
"""生成V3接口的Authorization头wx_cfg 指定商户签名配置。"""
cfg = _resolve_cfg(wx_cfg)
private_key = load_private_key(cfg)
mchid = cfg['MCHID']
serial_no = cfg['CERT_SERIAL_NO']
parsed = urlparse(url)
path = parsed.path
if parsed.query:
path += '?' + parsed.query
timestamp = str(int(time.time()))
nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32]
message = '\n'.join([method, path, timestamp, nonce, body or '']) + '\n'
signature = private_key.sign(
message.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256(),
)
signature_b64 = base64.b64encode(signature).decode('utf-8')
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",'
f'timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
)
def _resolve_platform_pub_path(wx_cfg, serial=None):
"""兼容:既可是目录/{serial}.pem也可是直接指向公钥文件路径。"""
base = (wx_cfg or {}).get('PLATFORM_PUB_KEY_PATH') or (wx_cfg or {}).get('PLATFORM_CERT_DIR') or ''
base = str(base).strip()
if not base:
return None
if os.path.isfile(base):
return base
if serial:
candidate = os.path.join(base, f'{serial}.pem')
if os.path.isfile(candidate):
return candidate
return None
def verify_wechat_sign(headers, body, wx_cfg=None):
"""验证微信回调签名(用 cryptography不依赖 rsa 包)。可指定商户或试全部。"""
serial = headers.get('Wechatpay-Serial')
signature = headers.get('Wechatpay-Signature')
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
if not all([serial, signature, timestamp, nonce]):
return False
message = '\n'.join([timestamp, nonce, body]) + '\n'
signature_bytes = base64.b64decode(signature)
cfgs = []
if wx_cfg:
cfgs = [wx_cfg]
else:
from utils.wechat_mch_service import list_wx_cfgs_for_callback
cfgs = list_wx_cfgs_for_callback()
for cfg in cfgs:
cert_path = _resolve_platform_pub_path(cfg, serial=serial)
if not cert_path or not os.path.exists(cert_path):
continue
try:
with open(cert_path, 'rb') as f:
pub_key = _load_platform_public_key(f.read())
pub_key.verify(
signature_bytes,
message.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception as e:
logger.debug('回调验签未通过 path=%s err=%s', cert_path, e)
continue
return False
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext, wx_cfg=None):
"""用指定商户 APIv3 密钥解密回调。"""
cfg = _resolve_cfg(wx_cfg)
api_v3_key = cfg['API_V3_KEY'].encode('utf-8')
nonce_bytes = nonce.encode('utf-8')
ciphertext_bytes = base64.b64decode(ciphertext)
cipher = AES.new(api_v3_key, AES.MODE_GCM, nonce=nonce_bytes)
if associated_data:
cipher.update(associated_data.encode('utf-8'))
plaintext = cipher.decrypt_and_verify(ciphertext_bytes[:-16], ciphertext_bytes[-16:])
return plaintext.decode('utf-8')
def decrypt_callback_with_merchants(associated_data, nonce, ciphertext):
"""
回调解密:因密文未解出前无法知道 out_bill_no
仅对启用商户密钥做 AES 尝试(通常很少),成功后返回 (plaintext, wx_cfg)。
业务侧再用 out_bill_no 反查记录上的 mch_id对齐一致性。
"""
from utils.wechat_mch_service import list_wx_cfgs_for_callback
last_err = None
for cfg in list_wx_cfgs_for_callback():
try:
text = decrypt_callback_ciphertext(associated_data, nonce, ciphertext, wx_cfg=cfg)
return text, cfg
except Exception as e:
last_err = e
continue
raise ValueError(f'所有商户密钥均无法解密回调: {last_err}')