From c3422f7c67ec08c5ef7edb4122aaae15e52e46a8 Mon Sep 17 00:00:00 2001 From: core Date: Mon, 18 May 2026 01:33:23 +0500 Subject: [PATCH] Key Encryption --- backend/app/api/router.py | 2 + backend/app/api/v1/health.py | 2 +- backend/app/api/v1/vault.py | 118 +++++++++++ backend/app/core/bootstrap.py | 1 + backend/app/core/security.py | 40 +++- backend/app/main.py | 38 +++- backend/app/models/vault.py | 34 ++++ backend/app/services/vault.py | 269 ++++++++++++++++++++++++++ frontend/src/api/client.ts | 41 ++++ frontend/src/components/AppLayout.tsx | 49 ++++- frontend/src/pages/Settings.tsx | 253 +++++++++++++++++++++++- 11 files changed, 831 insertions(+), 16 deletions(-) create mode 100644 backend/app/api/v1/vault.py create mode 100644 backend/app/models/vault.py create mode 100644 backend/app/services/vault.py diff --git a/backend/app/api/router.py b/backend/app/api/router.py index f6e50d7..294fd07 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py index 81ccbaf..a82de9b 100644 --- a/backend/app/api/v1/health.py +++ b/backend/app/api/v1/health.py @@ -1,7 +1,7 @@ from fastapi import APIRouter APP_NAME = "ROSzetta" -APP_VERSION = "0.6.0" +APP_VERSION = "0.7.0" router = APIRouter() diff --git a/backend/app/api/v1/vault.py b/backend/app/api/v1/vault.py new file mode 100644 index 0000000..17bd44b --- /dev/null +++ b/backend/app/api/v1/vault.py @@ -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()} diff --git a/backend/app/core/bootstrap.py b/backend/app/core/bootstrap.py index af49ccf..6fe3f42 100644 --- a/backend/app/core/bootstrap.py +++ b/backend/app/core/bootstrap.py @@ -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() diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 9ec5d79..7b25094 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index a3294f1..32c0a40 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/vault.py b/backend/app/models/vault.py new file mode 100644 index 0000000..30313c8 --- /dev/null +++ b/backend/app/models/vault.py @@ -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 + ) diff --git a/backend/app/services/vault.py b/backend/app/services/vault.py new file mode 100644 index 0000000..072b650 --- /dev/null +++ b/backend/app/services/vault.py @@ -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: + + +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} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c57c303..035395d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { + const r = await api.get('/vault/status'); + return r.data; + }, + async init(master_password: string): Promise { + const r = await api.post('/vault/init', { master_password }); + return r.data; + }, + async unlock(master_password: string): Promise { + const r = await api.post('/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 { + const r = await api.post('/vault/rotate', { old_password, new_password }); + return r.data; + }, +}; diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 5a90687..dffd3ce 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -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(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 ( + + ); + } + if (!s.unlocked) { + return ( + + ); + } + return ( + + ); +} + function AlertsBell() { const navigate = useNavigate(); const [count, setCount] = useState(0); @@ -431,6 +477,7 @@ export default function AppLayout() { v{version} )} + + + )} + + {/* --- UNLOCK (создан, но заблокирован) --- */} + {status && status.initialized && !status.unlocked && ( +
+
+ +

Разблокировать vault

+
+

+ Введите мастер-пароль, чтобы возобновить опрос устройств и операции с их секретами. +

+
+ + setPwd(e.target.value)} required autoFocus /> +
+ {msg &&
{msg.text}
} + +
+ )} + + {/* --- LOCK + ROTATE (разблокирован) --- */} + {status && status.initialized && status.unlocked && ( + <> +
+
+ +

Заблокировать сейчас

+
+

+ После блокировки DEK будет очищен из памяти, и до следующей разблокировки + автоопрос приостановится, а API устройств начнёт отвечать 423 Locked. +

+ + {msg && msg.kind === 'ok' && ( +
{msg.text}
+ )} +
+ +
+
+ +

Сменить мастер-пароль

+
+

+ Сами зашифрованные секреты при смене мастера не трогаются — перешифровывается + только защитная обёртка DEK. Это безопасно и быстро. +

+
+ + setOldPwd(e.target.value)} required /> +
+
+ + setNewPwd(e.target.value)} required /> +
+
+ + setNewPwd2(e.target.value)} required /> +
+ {msg && msg.kind === 'err' && ( +
{msg.text}
+ )} + +
+ + )} + + ); +}