Skip to content

Известные проблемы: Vault Auto-lock

Аудит: 2026-02-23 | Файлы: vault/manager.rs, vault/auto_lock.rs, vault/pin_encryption.rs, vault/types.rs, services/vault.rs, frontend/src/entities/vault/useVaultActivityTracker.ts

Результаты анализа механизма автоблокировки vault и PIN rate limiting. Проблемы упорядочены по severity.

Сводная таблица

#SeverityТипПроблемаФайл(ы)Статус
1CriticalSecurityData wipe не выполняется, counter не сохраняетсяpin_encryption.rs, manager.rsFixed (2026-06-24)
2HighSecurityBackoff не enforce — только логpin_encryption.rs, manager.rsFixed (2026-06-24)
3HighSecurityRead-only эндпоинты сбрасывают auto-lock таймерservices/vault.rsFixed (2026-02-23)
4MediumBugSetup не ставит last_activity_at → vault не заблокируетсяmanager.rsFixed (2026-02-23)
5LowBugupdate_activity() работает на locked vaultmanager.rsFixed (2026-02-23)
6LowBugTOCTOU: confusing error вместо "vault locked"manager.rsFixed (2026-02-23)
7LowRedundancyTimer работает на locked/unconfigured vaultauto_lock.rsFixed (2026-02-23)
8LowRedundancyДвойной update_activity() в service + managerservices/vault.rs, manager.rsFixed (2026-02-23)
9LowDesignis_unlocked_svc одновременно heartbeat и checkservices/vault.rsFixed (2026-02-23)
10MediumRobustnessunwrap() на mutex → cascade panicmanager.rsFixed (2026-02-23)

1. Data wipe не выполняется

Severity: Critical Тип: Security Статус: Fixed (2026-06-24, MR #49) Файлы: vault/pin_encryption.rs, vault/manager.rs

Описание

При достижении wipe-порога (15 неудачных попыток) handle_failed_attempt возвращает Err через anyhow::bail!, но:

  1. Фактический wipe не вызывается. Строка "data wipe required" — просто текст ошибки. Нигде в коде не вызывается vault.reset().

  2. Счётчик не персистируется. Ошибка пробрасывается через ? ДО вызова save_pin_state():

rust
// manager.rs — unlock_with_pin(), failure path:
let can_retry = self.pin_encryption.handle_failed_attempt(&mut pin_state)?;
//                                                                       ^
//                          При attempt #15 — Err, propagation через ?
self.save_pin_state(&pin_state)?;  // ← НИКОГДА НЕ ВЫПОЛНИТСЯ

Последствия

Цикл: attempt #15 → bail → состояние в БД остаётся на #14 → перезапуск → загрузка #14 из БД → attempt #15 → bail → ... Wipe threshold достигается бесконечно, но ничего не происходит.

Исправлено (2026-06-24, #49): handle_failed_attempt() теперь возвращает FailedAttemptOutcome (значение, не Err), поэтому unlock_with_pin() всегда сначала персистит инкрементированный счётчик через save_pin_state(), затем при Wipe вызывает self.reset(). Покрыто prod-path тестом test_unlock_prod_path_wipes_at_threshold.


2. Exponential backoff не enforce

Severity: High Тип: Security Статус: Fixed (2026-06-24, MR #49) Файл: vault/pin_encryption.rs, vault/manager.rs

Описание

calculate_backoff_delay() вычисляет задержку и логирует её, но нигде не проверяется, прошло ли достаточно времени с последней попытки:

rust
// pin_encryption.rs:296-298
let delay_secs = calculate_backoff_delay(state.failed_attempts);
log::info!("[PIN] Next attempt allowed after {} seconds", delay_secs);
// ← Больше ничего. Следующий вызов unlock_with_pin не проверяет elapsed time.

unlock_with_pin() проверяет только is_currently_locked() (порог 10 попыток). Между попытками 1–9 серверной задержки нет.

Последствия

Документированный backoff (5с, 10с, 30с, 60с, 120с) — фикция. Атакующий может отправить 9 попыток подряд за ~9 секунд (ограничен только Argon2 ~1с/попытку).

Исправлено (2026-06-24, #49): unlock_with_pin() теперь отклоняет попытки внутри backoff-окна через PinState::backoff_remaining_secs(), вычисляемое из персистентных last_failed_attempt + failed_attempts (переживает рестарт). Покрыто prod-path тестом test_unlock_prod_path_enforces_backoff.


3. Read-only эндпоинты сбрасывают auto-lock

Severity: High Тип: Security Файл: services/vault.rs:248-257, 267-281, 294-303

Описание

Эндпоинты, которые только ЧИТАЮТ состояние, вызывают update_activity():

rust
// GET /api/vault/pin-state
vault.update_activity();  // ← Зачем? Это чтение состояния PIN
vault.get_pin_state()

// GET /api/vault/is-pin-set
vault.update_activity();  // ← Зачем? Это проверка конфигурации
Ok(vault.is_setup()?)

// GET /api/vault/status, /api/vault/has-recovery-phrase — аналогично

Последствия

  • Любой поллинг этих эндпоинтов держит vault открытым бесконечно
  • vaultStore.checkStatus() на фронтенде вызывает getVaultStatusupdate_activity() — vault не заблокируется пока фронтенд работает
  • Невозможно протестировать auto-lock при работающем приложении

Рекомендация

Разделить эндпоинты на две категории:

  • Activity endpoints (операции с секретами, unlock, heartbeat) — вызывают update_activity()
  • Status endpoints (проверка состояния) — НЕ вызывают update_activity()

Убрать update_activity() из: get_pin_state_svc, is_pin_set_svc, get_vault_status_svc, has_recovery_phrase_svc.


4. Setup не устанавливает last_activity_at

Severity: Medium Тип: Bug Файл: vault/manager.rs:180-215, 221-253

Описание

setup_with_pin() и setup_with_recovery_phrase() кэшируют master key (vault разблокирован), но не вызывают update_activity():

rust
// setup_with_pin:
*self.master_key.lock().unwrap() = Some(master_key);
// ← Нет update_activity(). last_activity_at остаётся None.

Последствия

Проверка таймаута в is_unlocked():

rust
if let Some(last_activity_at) = session.last_activity_at {
    // Не входит сюда — last_activity_at == None
}
true  // Vault вечно разблокирован

После первичной настройки vault не заблокируется по таймауту до первой vault-операции, которая установит last_activity_at.

Рекомендация

Добавить self.update_activity() в конец setup_with_pin() и setup_with_recovery_phrase().


5. update_activity() на заблокированном vault

Severity: Low Тип: Bug Файл: vault/manager.rs:583-586

Описание

rust
pub fn update_activity(&self) {
    let mut session = self.session_state.lock().unwrap();
    session.last_activity_at = Some(current_timestamp());
    // Не проверяет master_key — работает и на locked vault
}

Последовательность:

  1. lock()master_key = None, last_activity_at = None
  2. Сервисный эндпоинт → update_activity()last_activity_at = Some(now)
  3. Vault заблокирован, но last_activity_at не None — нарушение инварианта

Последствия

Не создаёт уязвимость (проверка master_key.is_none() стоит первой в is_unlocked()), но нарушает семантику: locked vault имеет непустой last_activity_at.

Рекомендация

rust
pub fn update_activity(&self) {
    if self.master_key.lock().unwrap().is_none() {
        return;  // Vault locked — не обновлять activity
    }
    let mut session = self.session_state.lock().unwrap();
    session.last_activity_at = Some(current_timestamp());
}

6. TOCTOU между is_unlocked() и операцией

Severity: Low Тип: Bug Файл: vault/manager.rs:440-448

Описание

rust
// save_secret:
if !self.is_unlocked() {           // Проверка — отпускает ВСЕ локи
    anyhow::bail!("Vault is locked");
}
// ← Между этими строками auto-lock timer может вызвать lock()
let master_key_guard = self.master_key.lock().unwrap();
let master_key = master_key_guard.as_ref()
    .context("Master key not available")?;  // ← Confusing error

Последствия

Пользователь получает ошибку "Master key not available" вместо понятного "Vault is locked". Аналогичная проблема в load_secret, change_pin.

Рекомендация

Убрать предварительный is_unlocked() check. Сразу захватывать master_key lock и проверять:

rust
let master_key_guard = self.master_key.lock().unwrap();
let master_key = master_key_guard.as_ref()
    .ok_or_else(|| anyhow::anyhow!("Vault is locked. Unlock first."))?;

7. Timer работает безусловно

Severity: Low Тип: Redundancy Файл: vault/auto_lock.rs:18-28

Описание

Auto-lock timer работает каждые 5 секунд всегда, даже когда:

  • Vault не настроен (нет PIN)
  • Vault уже заблокирован

Каждый тик захватывает VaultManager Mutex → master_key Mutex → session_state Mutex (через is_unlocked()).

Рекомендация

Добавить ранний выход:

rust
let vm = vault_manager.lock()...;
if !vm.is_setup().unwrap_or(false) || vm.master_key_is_none() {
    continue;  // Нечего блокировать
}
let _ = vm.is_unlocked();

Или: спавнить таймер при unlock, останавливать при lock.


8. Двойной update_activity()

Severity: Low Тип: Redundancy Файлы: services/vault.rs, services/signing.rs:21, services/pairing.rs:269, vault/manager.rs

Описание

Методы VaultManager (save_secret, load_secret, change_pin, delete_secret) вызывают update_activity() внутри себя. Сервисный слой (services/signing.rs, services/pairing.rs) тоже вызывает vault.update_activity() перед вызовом этих методов. Результат: два обновления last_activity_at за один запрос.

Рекомендация

Выбрать один уровень ответственности:

  • Вариант A: update_activity() только в VaultManager (удалить из сервисов)
  • Вариант B: update_activity() только в сервисном слое (удалить из manager-методов)

Вариант A проще и надёжнее — менеджер сам знает, какие операции считаются активностью.


9. is_unlocked_svc — противоречивая семантика

Severity: Low Тип: Design Файл: services/vault.rs:260-265

Описание

rust
// GET /api/vault/is-unlocked
vault.update_activity();    // Сбросить таймер неактивности
Ok(vault.is_unlocked())     // Проверить таймер неактивности

update_activity() ставит last_activity_at = now, поэтому следующий is_unlocked() всегда вернёт true (если master_key != None). Эндпоинт не может обнаружить таймаут — он сам его предотвращает.

Эндпоинт одновременно выполняет две функции:

  • Heartbeat (продление сессии)
  • Status check (проверка состояния)

Последствия

Нет чистого способа узнать состояние vault без продления сессии. Таймаут может обнаружить только background timer.

Рекомендация

Разделить на два эндпоинта:

  • GET /api/vault/is-unlocked — чистый status check, без update_activity()
  • POST /api/vault/heartbeat — продление сессии, вызывает update_activity()

Фронтенд activity tracker использует heartbeat, а status checks (visibility change, polling) используют is-unlocked.


10. unwrap() на Mutex — каскадная паника

Severity: Medium Тип: Robustness Файл: vault/manager.rs (повсеместно)

Описание

Все обращения к Mutex в VaultManager используют unwrap():

rust
self.master_key.lock().unwrap()
self.session_state.lock().unwrap()
self.db.lock().unwrap()

Если поток паникует с зажатым mutex, mutex становится poisoned. Все последующие unwrap() на этом mutex паникуют каскадно.

Auto-lock timer обрабатывает poisoning:

rust
// auto_lock.rs:21-24
Err(poisoned) => poisoned.into_inner(),

Но методы VaultManager — нет.

Рекомендация

Использовать helper:

rust
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
    match mutex.lock() {
        Ok(guard) => guard,
        Err(poisoned) => {
            log::warn!("[VAULT] Recovering poisoned mutex");
            poisoned.into_inner()
        }
    }
}

Диаграмма: проблемы в потоке данных

plantuml Diagram

Редизайн механизма auto-lock

Статус: implemented (2026-02-23) | Решает проблемы: #3, #4, #5, #6, #7, #8, #9, #10

Ключевой принцип

Auto-lock отвечает на вопрос «пользователь ушёл от устройства?». На этот вопрос может ответить только фронтенд — через UI-события (mousemove, keydown, pointerdown). Бекенд-операции с секретами (save, load, delete) не являются признаком присутствия — их могут инициировать фоновые процессы (sharing, pairing, sync) без участия пользователя.

Что меняется

АспектТекущий механизмПредложенный механизм
is_unlocked()Проверяет ключ + проверяет таймаут + вызывает lock()Чистая проверка: master_key.is_some(). Без side effects
Проверка таймаутаВнутри is_unlocked() — вызывается отовсюдуТолько в check_and_lock_if_expired() — вызывается только timer
update_activity()15+ call sites: service layer, manager methods, timerПереименован в touch_activity(), 5 call sites
Кто продлевает сессиюЛюбой vault эндпоинт, операции с секретамиТолько POST /heartbeat и unlock/setup
GET /is-unlockedHeartbeat + status check (противоречие)Чистый status check
HeartbeatОтсутствует как отдельное понятиеPOST /api/vault/heartbeat
Операции с секретамиВызывают is_unlocked() отдельно → TOCTOUАтомарно захватывают master_key lock
TimerВсегда работает, 3 mutex на тикРанний выход если vault locked

Три разделённых операции вместо одной

Текущий is_unlocked() совмещает три ответственности. В новом дизайне каждая — отдельная функция:

is_unlocked()                → чистая проверка (любой код, любой момент)
touch_activity()             → обновление таймера (только heartbeat и unlock/setup)
check_and_lock_if_expired()  → проверка таймаута и блокировка (только timer)

Backend: VaultManager

is_unlocked() — чистая проверка

rust
pub fn is_unlocked(&self) -> bool {
    lock_or_recover(&self.master_key).is_some()
}

Без проверки таймаута, без побочных эффектов. Можно вызывать из любого места.

touch_activity() — обновление таймера с guard

rust
pub fn touch_activity(&self) {
    if lock_or_recover(&self.master_key).is_none() {
        return;  // Vault locked — нечего обновлять
    }
    lock_or_recover(&self.session_state).last_activity_at = Some(current_timestamp());
}

Не работает на locked vault — инвариант состояния сохраняется.

check_and_lock_if_expired() — единственное место проверки таймаута

rust
pub fn check_and_lock_if_expired(&self) -> bool {
    if lock_or_recover(&self.master_key).is_none() {
        return false;
    }

    let session = lock_or_recover(&self.session_state);
    if session.auto_lock_timeout == 0 {
        return false;
    }

    let should_lock = match session.last_activity_at {
        None => true,  // Нет activity после unlock → lock немедленно
        Some(last) => current_timestamp().saturating_sub(last) > session.auto_lock_timeout,
    };

    drop(session);
    if should_lock {
        self.lock();
    }
    should_lock
}

Вызывается только background timer.

Операции с секретами — атомарный захват ключа

rust
pub fn save_secret(&self, secret_id: &str, secret_data: &[u8]) -> Result<()> {
    // Один захват — удерживается на всю операцию
    let master_key_guard = lock_or_recover(&self.master_key);
    let master_key = master_key_guard.as_ref()
        .ok_or_else(|| anyhow::anyhow!("Vault is locked. Unlock first."))?;

    // ... шифрование и запись в БД (master_key_guard удерживается) ...

    Ok(())
    // Нет touch_activity() — операции не продлевают сессию
}
  • Убран предварительный is_unlocked() check → нет TOCTOU
  • Timer не может вызвать lock() пока guard удерживается
  • Ошибка чёткая: "Vault is locked", а не "Master key not available"
  • Нет touch_activity() — операции не продлевают сессию

Setup и unlock — начальный timestamp

rust
pub fn setup_with_pin(&self, pin: &str) -> Result<()> {
    // ... существующая логика ...
    *lock_or_recover(&self.master_key) = Some(master_key);
    self.touch_activity();  // Начальный last_activity_at
    Ok(())
}

pub fn unlock_with_pin(&self, pin: &str) -> Result<UnlockResult> {
    // ... существующая логика ...
    *lock_or_recover(&self.master_key) = Some(master_key);
    self.touch_activity();  // Начальный last_activity_at
    Ok(UnlockResult { success: true, ... })
}

Вспомогательная функция для mutex

rust
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
    mutex.lock().unwrap_or_else(|poisoned| {
        log::warn!("[VAULT] Recovering poisoned mutex");
        poisoned.into_inner()
    })
}

Backend: Auto-lock timer

rust
pub fn spawn_auto_lock_timer(vault_manager: Arc<Mutex<VaultManager>>) {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_secs(5));
        loop {
            interval.tick().await;
            let vm = match vault_manager.lock() {
                Ok(guard) => guard,
                Err(poisoned) => poisoned.into_inner(),
            };
            // Единственный вызов check_and_lock_if_expired
            // Внутри — ранний выход если vault locked/unconfigured
            vm.check_and_lock_if_expired();
        }
    });
}

Backend: Service layer

rust
// ── Status endpoints (чистое чтение, без touch) ─────────────

#[api(GET, "/api/vault/is-unlocked")]
pub async fn is_unlocked_svc(vm: &Arc<Mutex<VaultManager>>) -> Result<bool, String> {
    Ok(vm.lock().unwrap().is_unlocked())
}

#[api(GET, "/api/vault/status")]
pub async fn get_vault_status_svc(vm: &Arc<Mutex<VaultManager>>) -> Result<VaultStatus, String> {
    let vault = vm.lock().unwrap();
    Ok(VaultStatus {
        is_setup: vault.is_setup().map_err(|e| e.to_string())?,
        is_unlocked: vault.is_unlocked(),
        secrets_count: if vault.is_unlocked() {
            vault.list_secrets().unwrap_or_default().len() as u32
        } else { 0 },
    })
}

// Аналогично без touch: get_pin_state_svc, is_pin_set_svc, has_recovery_phrase_svc

// ── Heartbeat (единственный способ продлить сессию извне) ────

#[api(POST, "/api/vault/heartbeat")]
pub async fn vault_heartbeat_svc(vm: &Arc<Mutex<VaultManager>>) -> Result<bool, String> {
    let vault = vm.lock().unwrap();
    vault.touch_activity();
    Ok(vault.is_unlocked())
}

// ── Операционные endpoints (touch внутри manager методов) ────
// unlock_vault_with_pin_svc, lock_vault_svc, change_vault_pin_svc и т.д.
// Убрать ВСЕ vault.update_activity() из service layer

Также убрать vault.update_activity() из services/signing.rs и services/pairing.rs.

Frontend: Activity Tracker

typescript
const THROTTLE_MS = 30_000;

export function useVaultActivityTracker() {
  const vaultStore = useVaultStore();
  let lastTouchTime = 0;
  let listenersAttached = false;

  function onUserActivity() {
    const now = Date.now();
    if (now - lastTouchTime < THROTTLE_MS) return;
    lastTouchTime = now;
    vaultStore.lastBackendActivityAt = now;
    vaultCommands.vaultHeartbeat().catch(() => {}); // POST /api/vault/heartbeat
  }

  // ... attach/detach listeners (без изменений) ...
}

Frontend: Vault Store

checkStatus() вызывает getVaultStatus()GET /api/vault/statusне продлевает сессию.

Полный список call sites touch_activity()

touch_activity()
├── POST /api/vault/heartbeat         ← frontend activity tracker (единственный внешний)
├── VaultManager::unlock_with_pin()             ← начальный timestamp сессии
├── VaultManager::unlock_with_recovery_phrase() ← начальный timestamp сессии
├── VaultManager::setup_with_pin()              ← начальный timestamp сессии
└── VaultManager::setup_with_recovery_phrase()  ← начальный timestamp сессии

5 точек вызова. Все 15+ текущих update_activity() в service layer и manager methods — удаляются.

Матрица: какие проблемы решает каждое изменение

ИзменениеРешает
is_unlocked() — чистая проверка, без timeout logic#9 (семантика)
touch_activity() с guard на master_key.is_some()#5 (state inconsistency)
touch_activity() в setup/unlock#4 (вечный unlock после setup)
Операции держат master_key lock атомарно#6 (TOCTOU)
check_and_lock_if_expired() с ранним выходом#7 (timer redundancy)
touch_activity() не вызывается из операций и сервисов#3 (read-only reset), #8 (double call)
POST /heartbeat отдельно от GET /is-unlocked#3 (read-only reset), #9 (семантика)
lock_or_recover() вместо unwrap()#10 (cascade panic)

Диаграмма последовательности

plantuml Diagram

Файлы для изменения

ФайлИзменение
backend/src/vault/manager.rsРазделить is_unlocked() на 3 функции; операции без pre-check; lock_or_recover(); touch_activity() в setup/unlock
backend/src/vault/auto_lock.rsВызывать check_and_lock_if_expired() вместо is_unlocked()
backend/src/services/vault.rsНовый vault_heartbeat_svc; убрать все update_activity(); is_unlocked_svc без touch
backend/src/services/signing.rsУбрать vault.update_activity()
backend/src/services/pairing.rsУбрать vault.update_activity()
backend/src/commands/vault.rsДобавить re-export для heartbeat
backend/src/api/router.rsДобавить route для heartbeat (headless)
frontend/src/entities/vault/useVaultActivityTracker.tsВызывать vaultHeartbeat() вместо isUnlocked()
frontend/src/shared/api/bindingsПерегенерировать TypeScript bindings