Известные проблемы: 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 | Тип | Проблема | Файл(ы) | Статус |
|---|---|---|---|---|---|
| 1 | Critical | Security | Data wipe не выполняется, counter не сохраняется | pin_encryption.rs, manager.rs | Fixed (2026-06-24) |
| 2 | High | Security | Backoff не enforce — только лог | pin_encryption.rs, manager.rs | Fixed (2026-06-24) |
| 3 | High | Security | Read-only эндпоинты сбрасывают auto-lock таймер | services/vault.rs | Fixed (2026-02-23) |
| 4 | Medium | Bug | Setup не ставит last_activity_at → vault не заблокируется | manager.rs | Fixed (2026-02-23) |
| 5 | Low | Bug | update_activity() работает на locked vault | manager.rs | Fixed (2026-02-23) |
| 6 | Low | Bug | TOCTOU: confusing error вместо "vault locked" | manager.rs | Fixed (2026-02-23) |
| 7 | Low | Redundancy | Timer работает на locked/unconfigured vault | auto_lock.rs | Fixed (2026-02-23) |
| 8 | Low | Redundancy | Двойной update_activity() в service + manager | services/vault.rs, manager.rs | Fixed (2026-02-23) |
| 9 | Low | Design | is_unlocked_svc одновременно heartbeat и check | services/vault.rs | Fixed (2026-02-23) |
| 10 | Medium | Robustness | unwrap() на mutex → cascade panic | manager.rs | Fixed (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!, но:
Фактический wipe не вызывается. Строка
"data wipe required"— просто текст ошибки. Нигде в коде не вызываетсяvault.reset().Счётчик не персистируется. Ошибка пробрасывается через
?ДО вызоваsave_pin_state():
// 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() вычисляет задержку и логирует её, но нигде не проверяется, прошло ли достаточно времени с последней попытки:
// 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():
// 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()на фронтенде вызываетgetVaultStatus→update_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():
// setup_with_pin:
*self.master_key.lock().unwrap() = Some(master_key);
// ← Нет update_activity(). last_activity_at остаётся None.Последствия
Проверка таймаута в is_unlocked():
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
Описание
pub fn update_activity(&self) {
let mut session = self.session_state.lock().unwrap();
session.last_activity_at = Some(current_timestamp());
// Не проверяет master_key — работает и на locked vault
}Последовательность:
lock()→master_key = None,last_activity_at = None- Сервисный эндпоинт →
update_activity()→last_activity_at = Some(now) - Vault заблокирован, но
last_activity_atне None — нарушение инварианта
Последствия
Не создаёт уязвимость (проверка master_key.is_none() стоит первой в is_unlocked()), но нарушает семантику: locked vault имеет непустой last_activity_at.
Рекомендация
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
Описание
// 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 и проверять:
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()).
Рекомендация
Добавить ранний выход:
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
Описание
// 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():
self.master_key.lock().unwrap()
self.session_state.lock().unwrap()
self.db.lock().unwrap()Если поток паникует с зажатым mutex, mutex становится poisoned. Все последующие unwrap() на этом mutex паникуют каскадно.
Auto-lock timer обрабатывает poisoning:
// auto_lock.rs:21-24
Err(poisoned) => poisoned.into_inner(),Но методы VaultManager — нет.
Рекомендация
Использовать helper:
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()
}
}
}Диаграмма: проблемы в потоке данных
Редизайн механизма 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-unlocked | Heartbeat + 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() — чистая проверка
pub fn is_unlocked(&self) -> bool {
lock_or_recover(&self.master_key).is_some()
}Без проверки таймаута, без побочных эффектов. Можно вызывать из любого места.
touch_activity() — обновление таймера с guard
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() — единственное место проверки таймаута
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.
Операции с секретами — атомарный захват ключа
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
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
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
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
// ── 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
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) |
Диаграмма последовательности
Файлы для изменения
| Файл | Изменение |
|---|---|
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 |