zcbot/core/external_systems/crypto.py

118 lines
4.2 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.

"""外部系统凭据列加密。
与早期微信绑定不同,这里没有明文降级:未配置 master key 时拒绝创建和调用。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
from cryptography.exceptions import InvalidTag
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
_LEGACY_PREFIX = "v1:"
_PREFIX = "v2:"
_ENV = "ZCBOT_CREDENTIAL_MASTER_KEY"
_KEY_ID_ENV = "ZCBOT_CREDENTIAL_KEY_ID"
_PREVIOUS_KEYS_ENV = "ZCBOT_CREDENTIAL_PREVIOUS_KEYS"
def configured() -> bool:
return len(os.getenv(_ENV, "").strip()) >= 32
def _keyring() -> tuple[str, dict[str, str]]:
current = os.getenv(_ENV, "").strip()
if len(current) < 32:
raise RuntimeError(f"{_ENV} 至少需要 32 个字符")
current_id = os.getenv(_KEY_ID_ENV, "primary").strip() or "primary"
if ":" in current_id or len(current_id) > 64:
raise RuntimeError(f"{_KEY_ID_ENV} 格式无效")
keys = {current_id: current}
raw_previous = os.getenv(_PREVIOUS_KEYS_ENV, "").strip()
if raw_previous:
try:
previous = json.loads(raw_previous)
except ValueError as exc:
raise RuntimeError(f"{_PREVIOUS_KEYS_ENV} 必须是 JSON 对象") from exc
if not isinstance(previous, dict):
raise RuntimeError(f"{_PREVIOUS_KEYS_ENV} 必须是 JSON 对象")
for key_id, secret in previous.items():
key_id = str(key_id).strip()
secret = str(secret).strip()
if not key_id or ":" in key_id or len(key_id) > 64 or len(secret) < 32:
raise RuntimeError(f"{_PREVIOUS_KEYS_ENV} 包含无效密钥")
keys.setdefault(key_id, secret)
return current_id, keys
def _aes_key(secret: str) -> bytes:
return hashlib.sha256(secret.encode("utf-8")).digest()
def encrypt_secret(value: str, *, aad: str = "") -> str:
if not isinstance(value, str) or not value:
raise ValueError("credential value must be a non-empty string")
key_id, keys = _keyring()
nonce = secrets.token_bytes(12)
ciphertext = AESGCM(_aes_key(keys[key_id])).encrypt(
nonce,
value.encode("utf-8"),
aad.encode("utf-8"),
)
encoded = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
return f"{_PREFIX}{key_id}:{encoded}"
def decrypt_secret(value: str, *, aad: str = "") -> str:
if not isinstance(value, str):
raise RuntimeError("外部系统凭据格式无效")
if value.startswith(_PREFIX):
try:
_, key_id, encoded = value.split(":", 2)
_, keys = _keyring()
secret = keys[key_id]
payload = base64.urlsafe_b64decode(encoded.encode("ascii"))
plaintext = AESGCM(_aes_key(secret)).decrypt(
payload[:12],
payload[12:],
aad.encode("utf-8"),
)
return plaintext.decode("utf-8")
except (KeyError, ValueError, InvalidTag, UnicodeDecodeError) as exc:
raise RuntimeError(
"外部系统凭据无法解密,密钥或绑定上下文可能已变化"
) from exc
if not value.startswith(_LEGACY_PREFIX):
raise RuntimeError("外部系统凭据格式无效")
# 0027 前的 Fernet 密文没有 key id/AAD仅用于滚动迁移时读取。
try:
_, keys = _keyring()
for secret in keys.values():
digest = hashlib.sha256(secret.encode("utf-8")).digest()
try:
return (
Fernet(base64.urlsafe_b64encode(digest))
.decrypt(value[len(_LEGACY_PREFIX) :].encode("ascii"))
.decode("utf-8")
)
except InvalidToken:
continue
except (RuntimeError, UnicodeDecodeError) as exc:
raise RuntimeError("外部系统凭据无法解密master key 可能已变化") from exc
raise RuntimeError("外部系统凭据无法解密master key 可能已变化")
def mask_username(username: str) -> str:
username = (username or "").strip()
if not username:
return "***"
if len(username) <= 2:
return username[0] + "*"
return username[:2] + "***" + username[-1:]