122 lines
3.8 KiB
Python
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()
|