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}
|
||||
Reference in New Issue
Block a user