"""外部系统凭据列加密。 与早期微信绑定不同,这里没有明文降级:未配置 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:]