from __future__ import annotations import base64 import hashlib import os import secrets from contextlib import suppress from dataclasses import dataclass from pathlib import Path from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerifyMismatchError from cryptography.hazmat.primitives.ciphers.aead import AESGCM from app.core.errors import AppError PASSWORD_HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4) @dataclass(slots=True) class EncryptedValue: nonce: str ciphertext: str key_version: int = 1 class SecretCipher: def __init__(self, key: bytes) -> None: if len(key) != 32: raise ValueError("Master key must be exactly 32 bytes") self._cipher = AESGCM(key) @classmethod def load( cls, *, configured_key: str | None, key_file: Path, database_path: Path, ) -> SecretCipher: if configured_key: try: key = base64.urlsafe_b64decode(configured_key.encode("ascii")) except (ValueError, UnicodeError) as exc: raise AppError( "ROTATOR_MASTER_KEY 必须是 32 字节的 URL-safe Base64", code="INVALID_MASTER_KEY", status_code=500, ) from exc return cls(key) if key_file.exists(): try: return cls(base64.urlsafe_b64decode(key_file.read_bytes().strip())) except (ValueError, OSError) as exc: raise AppError( "主密钥文件无法读取或已损坏", code="INVALID_MASTER_KEY_FILE", status_code=500, ) from exc if database_path.exists(): raise AppError( "数据库已存在,但主密钥文件缺失;请恢复备份后再启动", code="MASTER_KEY_MISSING", status_code=500, ) key_file.parent.mkdir(parents=True, exist_ok=True) key = AESGCM.generate_key(bit_length=256) encoded = base64.urlsafe_b64encode(key) key_file.write_bytes(encoded) with suppress(OSError): os.chmod(key_file, 0o600) return cls(key) @staticmethod def _aad(name: str, key_version: int) -> bytes: return f"fluxip:{name}:v{key_version}".encode() def encrypt(self, name: str, plaintext: str) -> EncryptedValue: nonce = os.urandom(12) ciphertext = self._cipher.encrypt(nonce, plaintext.encode("utf-8"), self._aad(name, 1)) return EncryptedValue( nonce=base64.urlsafe_b64encode(nonce).decode("ascii"), ciphertext=base64.urlsafe_b64encode(ciphertext).decode("ascii"), ) def decrypt(self, name: str, value: EncryptedValue) -> str: try: plaintext = self._cipher.decrypt( base64.urlsafe_b64decode(value.nonce), base64.urlsafe_b64decode(value.ciphertext), self._aad(name, value.key_version), ) except Exception as exc: raise AppError( f"密钥 {name} 无法解密,请检查主密钥是否正确", code="SECRET_DECRYPTION_FAILED", status_code=500, ) from exc return plaintext.decode("utf-8") def hash_password(password: str) -> str: return PASSWORD_HASHER.hash(password) def verify_password(password_hash: str, password: str) -> bool: try: return PASSWORD_HASHER.verify(password_hash, password) except (VerifyMismatchError, InvalidHashError): return False def new_token() -> str: return secrets.token_urlsafe(32) def token_hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest()