新增了包括密钥加解密、配置健康检查、原子写入、审计、解析器以及 scrubber 在内的完整 secrets 模块,实现了明文密钥检测替换、密钥引用解析和配置安全校验能力
115 lines
3.4 KiB
Python
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
|