FluxIP/app/core/security.py

122 lines
3.8 KiB
Python

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()