ForcePilot/backend/package/yuxi/channel/secrets/crypto.py
Kris 3ce713eb9e feat(secrets): 新增完整的密钥管理工具链
新增了包括密钥加解密、配置健康检查、原子写入、审计、解析器以及 scrubber 在内的完整 secrets 模块,实现了明文密钥检测替换、密钥引用解析和配置安全校验能力
2026-05-21 10:32:14 +08:00

115 lines
3.4 KiB
Python

import base64
import hashlib
import logging
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
logger = logging.getLogger(__name__)
_CIPHER_VERSION = b"\x01"
_KEY_BYTES = 32
_MIN_KEY_LENGTH = 32
_PBKDF2_ITERATIONS = 480_000
class CryptoError(Exception):
pass
def _derive_key() -> bytes:
key = os.getenv("FORCEPILOT_SECRET_ENCRYPTION_KEY", "")
if not key:
raise CryptoError("FORCEPILOT_SECRET_ENCRYPTION_KEY must be set and at least 32 characters long")
raw = key.encode("utf-8")
if len(raw) < _MIN_KEY_LENGTH:
raise CryptoError(f"FORCEPILOT_SECRET_ENCRYPTION_KEY must be at least {_MIN_KEY_LENGTH} bytes, got {len(raw)}")
deterministic_salt = hashlib.sha256(b"forcepilot:secret:salt:" + raw).digest()[:16]
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=_KEY_BYTES,
salt=deterministic_salt,
iterations=_PBKDF2_ITERATIONS,
)
derived = kdf.derive(raw)
return deterministic_salt + derived
_ENCRYPTION_KEY: bytes | None = None
def get_encryption_key() -> bytes:
global _ENCRYPTION_KEY
if _ENCRYPTION_KEY is None:
_ENCRYPTION_KEY = _derive_key()
return _ENCRYPTION_KEY
def refresh_encryption_key() -> None:
global _ENCRYPTION_KEY
_ENCRYPTION_KEY = _derive_key()
logger.info("Encryption key refreshed")
def encrypt_secret(plaintext: str) -> str:
if not plaintext:
return plaintext
try:
key_material = get_encryption_key()
salt = key_material[:16]
key = key_material[16:]
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
payload = _CIPHER_VERSION + salt + nonce + ciphertext
return base64.urlsafe_b64encode(payload).decode("ascii")
except CryptoError:
raise
except Exception as e:
raise CryptoError(f"Failed to encrypt secret: {e}") from e
def decrypt_secret(ciphertext_b64: str) -> str:
if not ciphertext_b64:
return ciphertext_b64
try:
payload = base64.urlsafe_b64decode(ciphertext_b64)
except Exception as e:
raise CryptoError(f"Failed to base64-decode ciphertext: {e}") from e
if len(payload) < 1 + 16 + 12 + 16:
raise CryptoError("Ciphertext too short for valid AES-GCM payload")
version = payload[0:1]
if version != _CIPHER_VERSION:
raise CryptoError(f"Unknown cipher version: {version!r}")
salt = payload[1:17]
nonce = payload[17:29]
encrypted = payload[29:]
try:
key_material = get_encryption_key()
stored_salt = key_material[:16]
if salt != stored_salt:
logger.warning("Salt mismatch in ciphertext: encryption key may have been rotated")
key = key_material[16:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, encrypted, None).decode("utf-8")
except CryptoError:
raise
except Exception as e:
raise CryptoError(f"Failed to decrypt secret (key mismatch or tampering): {e}") from e
def is_encrypted(value: str) -> bool:
if not value:
return False
try:
payload = base64.urlsafe_b64decode(value)
return len(payload) > 29 and payload[0:1] == _CIPHER_VERSION
except Exception:
return False