Key Encryption
This commit is contained in:
@@ -12,6 +12,7 @@ from .v1 import firmware as firmware_router
|
||||
from .v1 import health as health_router
|
||||
from .v1 import metrics as metrics_router
|
||||
from .v1 import settings as settings_router
|
||||
from .v1 import vault as vault_router
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health_router.router, tags=["health"])
|
||||
@@ -24,3 +25,4 @@ api_router.include_router(metrics_router.router, tags=["metrics"])
|
||||
api_router.include_router(cli_router.router, prefix="/cli", tags=["cli"])
|
||||
api_router.include_router(controller_backup_router.router, prefix="/controller/backup", tags=["controller"])
|
||||
api_router.include_router(settings_router.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(vault_router.router, prefix="/vault", tags=["vault"])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
APP_NAME = "ROSzetta"
|
||||
APP_VERSION = "0.6.0"
|
||||
APP_VERSION = "0.7.0"
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""API мастер-пароля / vault.
|
||||
|
||||
Эндпоинты:
|
||||
GET /api/v1/vault/status — состояние (initialized, unlocked); доступно всем авторизованным.
|
||||
POST /api/v1/vault/init — установить первичный мастер-пароль (admin, только если не init).
|
||||
POST /api/v1/vault/unlock — разблокировать DEK мастер-паролем (admin).
|
||||
POST /api/v1/vault/lock — забыть DEK (admin).
|
||||
POST /api/v1/vault/rotate — сменить мастер-пароль (admin).
|
||||
|
||||
После init/unlock автоматически выполняется миграция legacy v1-секретов в v2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.db import get_db
|
||||
from ...models.user import User
|
||||
from ...services.vault import (
|
||||
InvalidMasterPassword,
|
||||
VaultAlreadyInitialized,
|
||||
VaultError,
|
||||
VaultLocked,
|
||||
VaultNotInitialized,
|
||||
migrate_legacy_device_secrets,
|
||||
vault_service,
|
||||
)
|
||||
from ..deps import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InitPayload(BaseModel):
|
||||
master_password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class UnlockPayload(BaseModel):
|
||||
master_password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class RotatePayload(BaseModel):
|
||||
old_password: str = Field(min_length=1, max_length=256)
|
||||
new_password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def vault_status(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return vault_service.status(db).as_dict()
|
||||
|
||||
|
||||
@router.post("/init", status_code=status.HTTP_201_CREATED)
|
||||
def vault_init(
|
||||
payload: InitPayload,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
try:
|
||||
vault_service.init_master_password(db, payload.master_password)
|
||||
except VaultAlreadyInitialized as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc))
|
||||
except VaultError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
# После init обычно паролей ещё нет, но всё равно прогоним миграцию на всякий случай.
|
||||
migration = migrate_legacy_device_secrets(db)
|
||||
return {
|
||||
"status": vault_service.status(db).as_dict(),
|
||||
"migration": migration,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/unlock")
|
||||
def vault_unlock(
|
||||
payload: UnlockPayload,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
try:
|
||||
vault_service.unlock(db, payload.master_password)
|
||||
except VaultNotInitialized as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc))
|
||||
except InvalidMasterPassword as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc))
|
||||
except VaultError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
migration = migrate_legacy_device_secrets(db)
|
||||
return {
|
||||
"status": vault_service.status(db).as_dict(),
|
||||
"migration": migration,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
def vault_lock(
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
vault_service.lock()
|
||||
return {"unlocked": False}
|
||||
|
||||
|
||||
@router.post("/rotate")
|
||||
def vault_rotate(
|
||||
payload: RotatePayload,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
try:
|
||||
vault_service.rotate_master_password(db, payload.old_password, payload.new_password)
|
||||
except VaultNotInitialized as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc))
|
||||
except InvalidMasterPassword as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc))
|
||||
except VaultError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
return {"status": vault_service.status(db).as_dict()}
|
||||
@@ -19,6 +19,7 @@ def init_db() -> None:
|
||||
from ..models import metric as _metric # noqa: F401
|
||||
from ..models import settings as _settings # noqa: F401
|
||||
from ..models import interface_stat as _ifs # noqa: F401
|
||||
from ..models import vault as _vault # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_columns()
|
||||
|
||||
@@ -57,9 +57,19 @@ def decode_token(token: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
# --- Симметричное шифрование секретов устройств -----------------------------
|
||||
# Производный ключ из SECRET_KEY (для dev). В prod — KMS / Vault.
|
||||
# Двухслойная схема:
|
||||
# v1: Fernet от SHA256(SECRET_KEY) — устаревший формат (без префикса для
|
||||
# обратной совместимости). Только дешифрация. Запись новых v1 запрещена.
|
||||
# v2: AES-256-GCM от DEK из services.vault, префикс "v2:".
|
||||
#
|
||||
# encrypt_secret() требует, чтобы vault был инициализирован и разблокирован.
|
||||
# decrypt_secret() умеет читать оба формата.
|
||||
# Подробнее см. services/vault.py.
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
SECRET_PREFIX_V2 = "v2:"
|
||||
|
||||
|
||||
def _legacy_fernet() -> Fernet:
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
@@ -69,8 +79,30 @@ def _fernet() -> Fernet:
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
return _fernet().encrypt(value.encode()).decode()
|
||||
"""Шифрует секрет DEK'ом из vault. Если vault ещё не инициализирован
|
||||
(свежий апгрейд с 0.6.x) — fallback на legacy Fernet, чтобы существующие
|
||||
сценарии не ломались. После /api/v1/vault/init все секреты пишутся как v2.
|
||||
"""
|
||||
from ..services.vault import vault_service # локальный импорт против цикла
|
||||
initialized = vault_service.is_initialized_cached()
|
||||
if initialized is False:
|
||||
# legacy v1: пишем Fernet-токен без префикса
|
||||
return _legacy_fernet().encrypt(value.encode()).decode()
|
||||
# initialized is True (или None — тогда поверим, что инициализирован, и
|
||||
# если на самом деле нет — VaultNotInitialized всплывёт; админу всё равно
|
||||
# пора создавать мастер-пароль).
|
||||
return vault_service.encrypt_secret(value)
|
||||
|
||||
|
||||
def decrypt_secret(token: str) -> str:
|
||||
return _fernet().decrypt(token.encode()).decode()
|
||||
"""Дешифрует секрет. Поддерживает v2 (vault) и legacy v1 (SECRET_KEY)."""
|
||||
if token.startswith(SECRET_PREFIX_V2):
|
||||
from ..services.vault import vault_service
|
||||
return vault_service.decrypt_secret_v2(token)
|
||||
# Legacy: Fernet-токен без префикса.
|
||||
return _legacy_fernet().decrypt(token.encode()).decode()
|
||||
|
||||
|
||||
def is_legacy_secret(token: str) -> bool:
|
||||
"""True, если секрет ещё не мигрирован на v2 (Fernet от SECRET_KEY)."""
|
||||
return not token.startswith(SECRET_PREFIX_V2)
|
||||
|
||||
+37
-1
@@ -3,14 +3,16 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from .api.router import api_router
|
||||
from .core.bootstrap import init_db
|
||||
from .core.config import get_settings
|
||||
from .core.db import SessionLocal
|
||||
from .services.vault import VaultLocked, VaultNotInitialized, vault_service
|
||||
|
||||
|
||||
def _job_firmware_check() -> None:
|
||||
@@ -36,6 +38,14 @@ def _job_probe_devices() -> None:
|
||||
fetch_identity, fetch_interface_stats, fetch_resource, parse_uptime,
|
||||
)
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Если vault уже инициализирован, но заблокирован — пропускаем итерацию:
|
||||
# без DEK не получится расшифровать password_enc устройств в формате v2.
|
||||
# До инициализации (legacy-режим) опрос продолжается со старым ключом из SECRET_KEY.
|
||||
if vault_service.is_initialized_cached() is True and not vault_service.is_unlocked():
|
||||
logger.info("probe_devices: vault locked, пропускаем итерацию")
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for d in db.query(Device).all():
|
||||
@@ -160,6 +170,18 @@ async def lifespan(_: FastAPI):
|
||||
logger.info("Starting ROSzetta API ({} env)", settings.app_env)
|
||||
init_db()
|
||||
|
||||
# Прогреваем кеш «vault initialized?» — нужен encrypt_secret() для legacy-fallback
|
||||
# и probe-джобе, чтобы решать skip/run без обращения к БД.
|
||||
try:
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
initialized = vault_service.refresh_initialized_cache(_db)
|
||||
logger.info("Vault initialized={}, unlocked={}", initialized, vault_service.is_unlocked())
|
||||
finally:
|
||||
_db.close()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("vault init-cache refresh failed: {}", exc)
|
||||
|
||||
# FTP-сервер для приёма push-бэкапов от MikroTik
|
||||
try:
|
||||
from .services.backup_ftp_server import start_server
|
||||
@@ -225,6 +247,20 @@ def create_app() -> FastAPI:
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.exception_handler(VaultLocked)
|
||||
async def _vault_locked(_: Request, exc: VaultLocked) -> JSONResponse:
|
||||
# 423 Locked — стандартный HTTP-код для «ресурс заперт»; фронт ловит его и
|
||||
# показывает форму ввода мастер-пароля.
|
||||
return JSONResponse(status_code=423, content={"detail": str(exc), "code": "vault_locked"})
|
||||
|
||||
@app.exception_handler(VaultNotInitialized)
|
||||
async def _vault_uninit(_: Request, exc: VaultNotInitialized) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=412,
|
||||
content={"detail": str(exc), "code": "vault_not_initialized"},
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Хранилище ключа шифрования секретов устройств (envelope encryption).
|
||||
|
||||
В таблице ровно одна запись (id=1). Поля:
|
||||
- kdf_salt — соль для PBKDF2 (16 B), base64
|
||||
- kdf_iterations — число итераций PBKDF2 (по умолчанию 200_000)
|
||||
- verifier — короткий тест-токен AES-GCM, зашифрованный KEK; используется,
|
||||
чтобы проверить корректность мастер-пароля без расшифровки DEK
|
||||
- dek_wrapped — DEK (32 B), завёрнутый AES-GCM от KEK; формат nonce|cipher|tag, base64
|
||||
- created_at / updated_at
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..core.db import Base
|
||||
|
||||
|
||||
class Vault(Base):
|
||||
__tablename__ = "vault"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kdf_salt: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
kdf_iterations: Mapped[int] = mapped_column(Integer, nullable=False, default=200_000)
|
||||
verifier: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
dek_wrapped: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Сервис мастер-пароля и шифрования секретов устройств (envelope encryption).
|
||||
|
||||
Архитектура:
|
||||
• DEK — случайные 32 байта, шифруют все секреты устройств AES-256-GCM.
|
||||
• KEK — производный ключ из мастер-пароля (PBKDF2-HMAC-SHA256, по умолчанию
|
||||
200_000 итераций, соль 16 B). KEK существует только в момент init/unlock/rotate.
|
||||
• В таблице `vault` хранится: соль, число итераций, verifier (короткий
|
||||
AES-GCM-токен от KEK для проверки пароля) и dek_wrapped (DEK, завёрнутый KEK).
|
||||
• После unlock'а DEK кешируется в памяти процесса; после рестарта vault
|
||||
автоматически locked, фоновые задачи и API устройств получают VaultLocked.
|
||||
|
||||
Мастер-пароль в БД НЕ хранится — только производные. Забыл — данные потеряны
|
||||
безвозвратно (это by design). См. /api/v1/vault/rotate для смены пароля.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.vault import Vault
|
||||
|
||||
DEFAULT_KDF_ITERATIONS = 200_000
|
||||
DEK_BYTES = 32 # AES-256
|
||||
SALT_BYTES = 16
|
||||
NONCE_BYTES = 12 # стандарт AES-GCM
|
||||
VERIFIER_PLAINTEXT = b"roszetta-vault-v2"
|
||||
SECRET_PREFIX_V2 = "v2:" # формат: v2:<base64(nonce|ct|tag)>
|
||||
|
||||
|
||||
class VaultError(Exception):
|
||||
"""Общий класс ошибок vault."""
|
||||
|
||||
|
||||
class VaultLocked(VaultError):
|
||||
"""Vault заблокирован — нужно ввести мастер-пароль через /api/v1/vault/unlock."""
|
||||
|
||||
|
||||
class VaultNotInitialized(VaultError):
|
||||
"""Мастер-пароль ещё не задан — нужен /api/v1/vault/init."""
|
||||
|
||||
|
||||
class VaultAlreadyInitialized(VaultError):
|
||||
"""Попытка повторного init — нужно использовать /rotate."""
|
||||
|
||||
|
||||
class InvalidMasterPassword(VaultError):
|
||||
"""Мастер-пароль не подходит."""
|
||||
|
||||
|
||||
# --- helpers --------------------------------------------------------------
|
||||
|
||||
def _b64e(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _b64d(text: str) -> bytes:
|
||||
return base64.b64decode(text.encode("ascii"))
|
||||
|
||||
|
||||
def _derive_kek(master_password: str, salt: bytes, iterations: int) -> bytes:
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=iterations,
|
||||
)
|
||||
return kdf.derive(master_password.encode("utf-8"))
|
||||
|
||||
|
||||
def _aead_encrypt(key: bytes, plaintext: bytes) -> str:
|
||||
"""AES-256-GCM. Возвращает base64(nonce|ct|tag)."""
|
||||
aead = AESGCM(key)
|
||||
nonce = os.urandom(NONCE_BYTES)
|
||||
ct_with_tag = aead.encrypt(nonce, plaintext, associated_data=None)
|
||||
return _b64e(nonce + ct_with_tag)
|
||||
|
||||
|
||||
def _aead_decrypt(key: bytes, token_b64: str) -> bytes:
|
||||
raw = _b64d(token_b64)
|
||||
if len(raw) < NONCE_BYTES + 16:
|
||||
raise InvalidMasterPassword("слишком короткий ciphertext")
|
||||
nonce, ct_with_tag = raw[:NONCE_BYTES], raw[NONCE_BYTES:]
|
||||
aead = AESGCM(key)
|
||||
return aead.decrypt(nonce, ct_with_tag, associated_data=None)
|
||||
|
||||
|
||||
# --- основной сервис -------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class VaultStatus:
|
||||
initialized: bool
|
||||
unlocked: bool
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"initialized": self.initialized, "unlocked": self.unlocked}
|
||||
|
||||
|
||||
class VaultService:
|
||||
"""Singleton-сервис. Держит DEK в памяти процесса."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._dek: Optional[bytes] = None
|
||||
self._lock = threading.RLock()
|
||||
# Кеш «инициализирован ли vault» — обновляется при init/status, чтобы
|
||||
# encrypt_secret() мог решить legacy-fallback без передачи db-сессии.
|
||||
self._initialized_cache: Optional[bool] = None
|
||||
|
||||
def refresh_initialized_cache(self, db: Session) -> bool:
|
||||
with self._lock:
|
||||
self._initialized_cache = db.query(Vault).first() is not None
|
||||
return self._initialized_cache
|
||||
|
||||
# ---- состояние ----
|
||||
def status(self, db: Session) -> VaultStatus:
|
||||
row = db.query(Vault).first()
|
||||
self._initialized_cache = row is not None
|
||||
return VaultStatus(initialized=row is not None, unlocked=self._dek is not None)
|
||||
|
||||
def is_unlocked(self) -> bool:
|
||||
return self._dek is not None
|
||||
|
||||
def is_initialized_cached(self) -> Optional[bool]:
|
||||
"""None — кеш ещё не заполнен; True/False — последнее состояние."""
|
||||
return self._initialized_cache
|
||||
|
||||
def _require_unlocked(self) -> bytes:
|
||||
if self._dek is None:
|
||||
raise VaultLocked("vault locked: введите мастер-пароль в Настройках → Безопасность")
|
||||
return self._dek
|
||||
|
||||
# ---- init / unlock / lock / rotate ----
|
||||
def init_master_password(self, db: Session, master_password: str) -> None:
|
||||
if not master_password or len(master_password) < 8:
|
||||
raise VaultError("мастер-пароль должен быть не короче 8 символов")
|
||||
with self._lock:
|
||||
existing = db.query(Vault).first()
|
||||
if existing is not None:
|
||||
raise VaultAlreadyInitialized("vault уже инициализирован — используйте rotate")
|
||||
|
||||
salt = os.urandom(SALT_BYTES)
|
||||
kek = _derive_kek(master_password, salt, DEFAULT_KDF_ITERATIONS)
|
||||
dek = secrets.token_bytes(DEK_BYTES)
|
||||
|
||||
row = Vault(
|
||||
kdf_salt=_b64e(salt),
|
||||
kdf_iterations=DEFAULT_KDF_ITERATIONS,
|
||||
verifier=_aead_encrypt(kek, VERIFIER_PLAINTEXT),
|
||||
dek_wrapped=_aead_encrypt(kek, dek),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
self._dek = dek # сразу разблокирован после init
|
||||
self._initialized_cache = True
|
||||
|
||||
def unlock(self, db: Session, master_password: str) -> None:
|
||||
with self._lock:
|
||||
row = db.query(Vault).first()
|
||||
if row is None:
|
||||
raise VaultNotInitialized("сначала установите мастер-пароль")
|
||||
|
||||
salt = _b64d(row.kdf_salt)
|
||||
kek = _derive_kek(master_password, salt, row.kdf_iterations)
|
||||
|
||||
# 1) проверяем пароль через verifier
|
||||
try:
|
||||
check = _aead_decrypt(kek, row.verifier)
|
||||
except Exception as exc: # noqa: BLE001 — любая ошибка дешифровки = неверный пароль
|
||||
raise InvalidMasterPassword("неверный мастер-пароль") from exc
|
||||
if check != VERIFIER_PLAINTEXT:
|
||||
raise InvalidMasterPassword("неверный мастер-пароль")
|
||||
|
||||
# 2) разворачиваем DEK
|
||||
dek = _aead_decrypt(kek, row.dek_wrapped)
|
||||
if len(dek) != DEK_BYTES:
|
||||
raise VaultError("повреждённый dek_wrapped")
|
||||
self._dek = dek
|
||||
self._initialized_cache = True
|
||||
|
||||
def lock(self) -> None:
|
||||
with self._lock:
|
||||
self._dek = None
|
||||
|
||||
def rotate_master_password(self, db: Session, old_password: str, new_password: str) -> None:
|
||||
if not new_password or len(new_password) < 8:
|
||||
raise VaultError("новый мастер-пароль должен быть не короче 8 символов")
|
||||
with self._lock:
|
||||
row = db.query(Vault).first()
|
||||
if row is None:
|
||||
raise VaultNotInitialized("vault не инициализирован")
|
||||
|
||||
# Сначала проверяем старый пароль и достаём DEK
|
||||
salt_old = _b64d(row.kdf_salt)
|
||||
kek_old = _derive_kek(old_password, salt_old, row.kdf_iterations)
|
||||
try:
|
||||
_ = _aead_decrypt(kek_old, row.verifier)
|
||||
dek = _aead_decrypt(kek_old, row.dek_wrapped)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise InvalidMasterPassword("текущий мастер-пароль неверен") from exc
|
||||
|
||||
# Генерим новую соль и перешифровываем verifier/DEK новым KEK
|
||||
new_salt = os.urandom(SALT_BYTES)
|
||||
kek_new = _derive_kek(new_password, new_salt, DEFAULT_KDF_ITERATIONS)
|
||||
row.kdf_salt = _b64e(new_salt)
|
||||
row.kdf_iterations = DEFAULT_KDF_ITERATIONS
|
||||
row.verifier = _aead_encrypt(kek_new, VERIFIER_PLAINTEXT)
|
||||
row.dek_wrapped = _aead_encrypt(kek_new, dek)
|
||||
db.commit()
|
||||
self._dek = dek # остаётся разблокированным с тем же DEK
|
||||
|
||||
# ---- шифрование секретов устройств ----
|
||||
def encrypt_secret(self, value: str) -> str:
|
||||
dek = self._require_unlocked()
|
||||
token = _aead_encrypt(dek, value.encode("utf-8"))
|
||||
return SECRET_PREFIX_V2 + token
|
||||
|
||||
def decrypt_secret_v2(self, token: str) -> str:
|
||||
dek = self._require_unlocked()
|
||||
if not token.startswith(SECRET_PREFIX_V2):
|
||||
raise VaultError("not a v2 ciphertext")
|
||||
payload = token[len(SECRET_PREFIX_V2):]
|
||||
try:
|
||||
return _aead_decrypt(dek, payload).decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise VaultError(f"не удалось расшифровать секрет: {exc}") from exc
|
||||
|
||||
|
||||
# Глобальный экземпляр (живёт всё время uvicorn-процесса)
|
||||
vault_service = VaultService()
|
||||
|
||||
|
||||
def migrate_legacy_device_secrets(db: Session) -> dict:
|
||||
"""Перешифровывает password_enc у всех устройств с v1 (Fernet от SECRET_KEY)
|
||||
в v2 (AES-GCM от DEK). Безопасно вызывать многократно — уже v2 пропускаются.
|
||||
|
||||
Возвращает {migrated, failed, skipped} для логов/UI.
|
||||
"""
|
||||
from ..core.security import _legacy_fernet, is_legacy_secret
|
||||
from ..models.device import Device
|
||||
|
||||
if not vault_service.is_unlocked():
|
||||
raise VaultLocked("нельзя мигрировать при заблокированном vault")
|
||||
|
||||
migrated = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
legacy = _legacy_fernet()
|
||||
|
||||
for d in db.query(Device).all():
|
||||
if not is_legacy_secret(d.password_enc):
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
plaintext = legacy.decrypt(d.password_enc.encode()).decode()
|
||||
except Exception: # noqa: BLE001 — старый ключ не подошёл, пропустим, чтобы не терять данные
|
||||
failed += 1
|
||||
continue
|
||||
d.password_enc = vault_service.encrypt_secret(plaintext)
|
||||
migrated += 1
|
||||
db.commit()
|
||||
return {"migrated": migrated, "failed": failed, "skipped": skipped}
|
||||
@@ -232,3 +232,44 @@ export interface HeartbeatOut {
|
||||
hours: number;
|
||||
devices: HeartbeatDevice[];
|
||||
}
|
||||
|
||||
// --- Vault (мастер-пароль / шифрование секретов устройств) -------------------
|
||||
|
||||
export interface VaultStatus {
|
||||
initialized: boolean;
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
export interface VaultMigration {
|
||||
migrated: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface VaultActionOut {
|
||||
status: VaultStatus;
|
||||
migration?: VaultMigration;
|
||||
}
|
||||
|
||||
export const vaultApi = {
|
||||
async status(): Promise<VaultStatus> {
|
||||
const r = await api.get<VaultStatus>('/vault/status');
|
||||
return r.data;
|
||||
},
|
||||
async init(master_password: string): Promise<VaultActionOut> {
|
||||
const r = await api.post<VaultActionOut>('/vault/init', { master_password });
|
||||
return r.data;
|
||||
},
|
||||
async unlock(master_password: string): Promise<VaultActionOut> {
|
||||
const r = await api.post<VaultActionOut>('/vault/unlock', { master_password });
|
||||
return r.data;
|
||||
},
|
||||
async lock(): Promise<{ unlocked: false }> {
|
||||
const r = await api.post<{ unlocked: false }>('/vault/lock');
|
||||
return r.data;
|
||||
},
|
||||
async rotate(old_password: string, new_password: string): Promise<VaultActionOut> {
|
||||
const r = await api.post<VaultActionOut>('/vault/rotate', { old_password, new_password });
|
||||
return r.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
CheckCircle2, AlertTriangle, Bell, Terminal,
|
||||
Menu, X, Settings as SettingsIcon,
|
||||
ChevronDown, ChevronUp,
|
||||
Lock, Unlock, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/store/auth';
|
||||
import { api, Device } from '@/api/client';
|
||||
import { api, Device, vaultApi, VaultStatus } from '@/api/client';
|
||||
import AboutModal from './AboutModal';
|
||||
import { useSettings } from '@/store/settings';
|
||||
import { pickOkMessage } from '@/utils/okMessages';
|
||||
@@ -118,6 +119,51 @@ function GlobalHealth() {
|
||||
);
|
||||
}
|
||||
|
||||
function VaultBadge() {
|
||||
const navigate = useNavigate();
|
||||
const [s, setS] = useState<VaultStatus | null>(null);
|
||||
useEffect(() => {
|
||||
const load = () => vaultApi.status().then(setS).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
if (!s) return null;
|
||||
const goto = () => navigate('/settings#security');
|
||||
// Три состояния: ok (зелёный замок открыт), locked (жёлтый замок закрыт), uninit (красный щит)
|
||||
if (!s.initialized) {
|
||||
return (
|
||||
<button
|
||||
onClick={goto}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-err hover:bg-white/[0.04]"
|
||||
title="Vault не инициализирован — задайте мастер-пароль"
|
||||
>
|
||||
<ShieldAlert size={14} /> <span className="hidden md:inline">vault</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (!s.unlocked) {
|
||||
return (
|
||||
<button
|
||||
onClick={goto}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-warn hover:bg-white/[0.04]"
|
||||
title="Vault заблокирован — введите мастер-пароль, опрос устройств приостановлен"
|
||||
>
|
||||
<Lock size={14} /> <span className="hidden md:inline">locked</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={goto}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-mk-ok hover:bg-white/[0.04]"
|
||||
title="Vault разблокирован — секреты устройств доступны"
|
||||
>
|
||||
<Unlock size={14} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsBell() {
|
||||
const navigate = useNavigate();
|
||||
const [count, setCount] = useState(0);
|
||||
@@ -431,6 +477,7 @@ export default function AppLayout() {
|
||||
v{version}
|
||||
</span>
|
||||
)}
|
||||
<VaultBadge />
|
||||
<AlertsBell />
|
||||
<button
|
||||
onClick={() => setAboutOpen(true)}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Database, Settings as SettingsIcon, Download, Upload, RefreshCw, Eye, Save,
|
||||
Globe, Palette, Tag, Activity, Radar, AlertTriangle, User as UserIcon, KeyRound,
|
||||
Lock, Unlock, ShieldCheck, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { api, AppSettings } from '@/api/client';
|
||||
import { api, AppSettings, vaultApi, VaultStatus, VaultMigration } from '@/api/client';
|
||||
import { useAuth } from '@/store/auth';
|
||||
import { useSettings } from '@/store/settings';
|
||||
import { useT, LOCALES, THEMES, HEARTBEAT_RANGES, PROBE_INTERVALS } from '@/i18n';
|
||||
@@ -19,11 +20,12 @@ const MENU_LABELS: Record<keyof AppSettings['menu'], string> = {
|
||||
settings: 'Настройки',
|
||||
};
|
||||
|
||||
type TabKey = 'general' | 'probe' | 'user' | 'menu' | 'backup';
|
||||
type TabKey = 'general' | 'probe' | 'user' | 'security' | 'menu' | 'backup';
|
||||
|
||||
function parseHash(h: string): TabKey {
|
||||
const v = h.replace(/^#/, '');
|
||||
if (v === 'users' || v === 'password' || v === 'user') return 'user';
|
||||
if (v === 'security' || v === 'vault' || v === 'master') return 'security';
|
||||
if (v === 'menu') return 'menu';
|
||||
if (v === 'backup') return 'backup';
|
||||
if (v === 'probe') return 'probe';
|
||||
@@ -32,11 +34,12 @@ function parseHash(h: string): TabKey {
|
||||
}
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||
{ key: 'general', label: 'Общие', icon: SettingsIcon },
|
||||
{ key: 'probe', label: 'Опрос', icon: Radar },
|
||||
{ key: 'user', label: 'Пользователь', icon: UserIcon },
|
||||
{ key: 'menu', label: 'Меню', icon: Eye },
|
||||
{ key: 'backup', label: 'Бэкап', icon: Database },
|
||||
{ key: 'general', label: 'Общие', icon: SettingsIcon },
|
||||
{ key: 'probe', label: 'Опрос', icon: Radar },
|
||||
{ key: 'user', label: 'Пользователь', icon: UserIcon },
|
||||
{ key: 'security', label: 'Безопасность', icon: ShieldCheck },
|
||||
{ key: 'menu', label: 'Меню', icon: Eye },
|
||||
{ key: 'backup', label: 'Бэкап', icon: Database },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -131,8 +134,9 @@ export default function SettingsPage() {
|
||||
const updUi = (k: keyof AppSettings['ui'], v: any) =>
|
||||
setDraft({ ...draft, ui: { ...ui, [k]: v } });
|
||||
|
||||
// На вкладке "Пользователь" своя кнопка сохранения — основная "Сохранить" не нужна.
|
||||
const showSaveBtn = tab !== 'user';
|
||||
// На вкладках "Пользователь" и "Безопасность" — свои кнопки в формах,
|
||||
// глобальный "Сохранить" не нужен.
|
||||
const showSaveBtn = tab !== 'user' && tab !== 'security';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
@@ -302,6 +306,8 @@ export default function SettingsPage() {
|
||||
|
||||
{tab === 'user' && <UserTab email={email} />}
|
||||
|
||||
{tab === 'security' && <SecurityTab />}
|
||||
|
||||
{tab === 'menu' && (
|
||||
<div className="card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -481,3 +487,232 @@ function UserTab({ email }: { email: string | null }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Вкладка «Безопасность» (мастер-пароль / vault) ----------
|
||||
|
||||
function SecurityTab() {
|
||||
const [status, setStatus] = useState<VaultStatus | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [migration, setMigration] = useState<VaultMigration | null>(null);
|
||||
|
||||
// init / unlock form
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [pwd2, setPwd2] = useState('');
|
||||
// rotate form
|
||||
const [oldPwd, setOldPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [newPwd2, setNewPwd2] = useState('');
|
||||
|
||||
const refresh = async () => {
|
||||
try { setStatus(await vaultApi.status()); }
|
||||
catch (ex: any) { setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? String(ex) }); }
|
||||
};
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
const doInit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null); setMigration(null);
|
||||
if (pwd.length < 8) { setMsg({ kind: 'err', text: 'Мастер-пароль должен быть не короче 8 символов' }); return; }
|
||||
if (pwd !== pwd2) { setMsg({ kind: 'err', text: 'Пароли не совпадают' }); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await vaultApi.init(pwd);
|
||||
setStatus(r.status);
|
||||
setMigration(r.migration ?? null);
|
||||
setPwd(''); setPwd2('');
|
||||
setMsg({ kind: 'ok', text: 'Мастер-пароль установлен. Vault разблокирован.' });
|
||||
} catch (ex: any) {
|
||||
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка инициализации' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const doUnlock = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null); setMigration(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await vaultApi.unlock(pwd);
|
||||
setStatus(r.status);
|
||||
setMigration(r.migration ?? null);
|
||||
setPwd('');
|
||||
setMsg({ kind: 'ok', text: 'Vault разблокирован.' });
|
||||
} catch (ex: any) {
|
||||
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка разблокировки' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const doLock = async () => {
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
await vaultApi.lock();
|
||||
await refresh();
|
||||
setMsg({ kind: 'ok', text: 'Vault заблокирован. Фоновые задачи опроса остановлены до следующей разблокировки.' });
|
||||
} catch (ex: any) {
|
||||
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка блокировки' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const doRotate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (newPwd.length < 8) { setMsg({ kind: 'err', text: 'Новый мастер-пароль не короче 8 символов' }); return; }
|
||||
if (newPwd !== newPwd2) { setMsg({ kind: 'err', text: 'Новые пароли не совпадают' }); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await vaultApi.rotate(oldPwd, newPwd);
|
||||
setStatus(r.status);
|
||||
setOldPwd(''); setNewPwd(''); setNewPwd2('');
|
||||
setMsg({ kind: 'ok', text: 'Мастер-пароль изменён. Все секреты остались валидными.' });
|
||||
} catch (ex: any) {
|
||||
setMsg({ kind: 'err', text: ex?.response?.data?.detail ?? 'Ошибка смены пароля' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const renderStatus = () => {
|
||||
if (!status) return <span className="text-mk-mute">Загрузка…</span>;
|
||||
if (!status.initialized) {
|
||||
return <span className="inline-flex items-center gap-1.5 text-mk-warn"><ShieldAlert size={14}/> не инициализирован</span>;
|
||||
}
|
||||
if (status.unlocked) {
|
||||
return <span className="inline-flex items-center gap-1.5 text-mk-ok"><Unlock size={14}/> разблокирован</span>;
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1.5 text-mk-warn"><Lock size={14}/> заблокирован</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck size={14} className="text-mk-accent2" />
|
||||
<h3 className="text-sm font-semibold">Шифрование секретов устройств</h3>
|
||||
</div>
|
||||
<p className="text-xs text-mk-mute leading-relaxed">
|
||||
Пароли подключения к RouterOS-устройствам хранятся в БД зашифрованными
|
||||
<b> AES-256-GCM</b>. Ключ шифрования (DEK) защищён вашим мастер-паролем
|
||||
(PBKDF2-HMAC-SHA256, 200 000 итераций). Мастер-пароль в БД <b>не сохраняется</b> —
|
||||
его помнит только администратор. После рестарта контейнера vault блокируется
|
||||
автоматически, до повторного ввода пароля фоновый опрос устройств приостанавливается.
|
||||
</p>
|
||||
<div className="text-sm">
|
||||
<span className="text-mk-mute">Статус: </span>{renderStatus()}
|
||||
</div>
|
||||
{migration && (
|
||||
<div className="text-[11px] text-mk-mute">
|
||||
Миграция legacy-секретов: перешифровано <b>{migration.migrated}</b>,
|
||||
пропущено уже-v2 <b>{migration.skipped}</b>,
|
||||
не удалось <b>{migration.failed}</b>.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- INIT (vault ещё не создан) --- */}
|
||||
{status && !status.initialized && (
|
||||
<form onSubmit={doInit} className="card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-mk-accent2" />
|
||||
<h3 className="text-sm font-semibold">Создать мастер-пароль</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-mk-warn flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Запомните его надёжно. Если потеряете — пароли устройств в БД восстановить
|
||||
будет нельзя, придётся завести устройства заново.
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute">Мастер-пароль (мин. 8 символов)</label>
|
||||
<input className="input" type="password" autoComplete="new-password" minLength={8}
|
||||
value={pwd} onChange={(e) => setPwd(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute">Повторите</label>
|
||||
<input className="input" type="password" autoComplete="new-password" minLength={8}
|
||||
value={pwd2} onChange={(e) => setPwd2(e.target.value)} required />
|
||||
</div>
|
||||
{msg && <div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>}
|
||||
<button className="btn-primary !text-xs" disabled={busy}>
|
||||
{busy ? 'Создаём…' : 'Создать мастер-пароль'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- UNLOCK (создан, но заблокирован) --- */}
|
||||
{status && status.initialized && !status.unlocked && (
|
||||
<form onSubmit={doUnlock} className="card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Unlock size={14} className="text-mk-accent2" />
|
||||
<h3 className="text-sm font-semibold">Разблокировать vault</h3>
|
||||
</div>
|
||||
<p className="text-xs text-mk-mute">
|
||||
Введите мастер-пароль, чтобы возобновить опрос устройств и операции с их секретами.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute">Мастер-пароль</label>
|
||||
<input className="input" type="password" autoComplete="current-password"
|
||||
value={pwd} onChange={(e) => setPwd(e.target.value)} required autoFocus />
|
||||
</div>
|
||||
{msg && <div className={`text-sm ${msg.kind === 'ok' ? 'text-mk-ok' : 'text-mk-err'}`}>{msg.text}</div>}
|
||||
<button className="btn-primary !text-xs" disabled={busy}>
|
||||
{busy ? 'Разблокируем…' : 'Разблокировать'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- LOCK + ROTATE (разблокирован) --- */}
|
||||
{status && status.initialized && status.unlocked && (
|
||||
<>
|
||||
<div className="card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={14} className="text-mk-accent2" />
|
||||
<h3 className="text-sm font-semibold">Заблокировать сейчас</h3>
|
||||
</div>
|
||||
<p className="text-xs text-mk-mute">
|
||||
После блокировки DEK будет очищен из памяти, и до следующей разблокировки
|
||||
автоопрос приостановится, а API устройств начнёт отвечать <b>423 Locked</b>.
|
||||
</p>
|
||||
<button type="button" className="btn-ghost !text-xs" disabled={busy} onClick={doLock}>
|
||||
<Lock size={13} /> Заблокировать
|
||||
</button>
|
||||
{msg && msg.kind === 'ok' && (
|
||||
<div className="text-sm text-mk-ok">{msg.text}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={doRotate} className="card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-mk-accent2" />
|
||||
<h3 className="text-sm font-semibold">Сменить мастер-пароль</h3>
|
||||
</div>
|
||||
<p className="text-xs text-mk-mute">
|
||||
Сами зашифрованные секреты при смене мастера не трогаются — перешифровывается
|
||||
только защитная обёртка DEK. Это безопасно и быстро.
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute">Текущий мастер-пароль</label>
|
||||
<input className="input" type="password" autoComplete="current-password"
|
||||
value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute">Новый мастер-пароль (мин. 8 символов)</label>
|
||||
<input className="input" type="password" autoComplete="new-password" minLength={8}
|
||||
value={newPwd} onChange={(e) => setNewPwd(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute">Повторите новый</label>
|
||||
<input className="input" type="password" autoComplete="new-password" minLength={8}
|
||||
value={newPwd2} onChange={(e) => setNewPwd2(e.target.value)} required />
|
||||
</div>
|
||||
{msg && msg.kind === 'err' && (
|
||||
<div className="text-sm text-mk-err">{msg.text}</div>
|
||||
)}
|
||||
<button className="btn-primary !text-xs" disabled={busy}>
|
||||
{busy ? 'Меняем…' : 'Сменить мастер-пароль'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user