Хранение секретных ключей в браузере: IndexedDB, Web Crypto, memory-only

Криптографические ключи в веб-приложениях существуют в условиях, где отсутствует физический контроль над средой исполнения. Браузерное окружение одновременно предоставляет удобство и расширяемую поверхность атак: XSS, вредоносные расширения, компрометация зависимостей, подмена CDN. В таких условиях выбор стратегии хранения приватных ключей определяет реальную стойкость всей криптосистемы на стороне клиента.

Библиотеки уровня TweetNaCl.js и nacl.js не решают задачу хранения — они предоставляют примитивы. Управление ключами остаётся задачей архитектуры приложения.


Memory-only модель хранения

Подход memory-only предполагает, что приватные ключи существуют только в оперативной памяти JavaScript-контекста и никогда не записываются в долговременные хранилища браузера.

Характеристики модели

  • ключ генерируется при старте сессии
  • хранится в переменной или замыкании
  • исчезает при обновлении страницы
  • не переживает перезапуск браузера
  • минимизирует поверхность атаки со стороны persistent storage

Преимущества

  • отсутствие следов в диске и IndexedDB
  • невозможность кражи через локальный доступ к профилю браузера
  • простота реализации
  • совместимость с CSP-ограничениями, запрещающими доступ к storage

Ограничения

  • потеря ключа при перезагрузке страницы
  • невозможность асинхронного восстановления сессии без дополнительного механизма
  • зависимость от UX-слоя (пользовательская аутентификация повторно)

Пример использования TweetNaCl.js

import nacl from "tweetnacl";

const keyPair = nacl.box.keyPair();

// приватный ключ существует только в памяти
const privateKey = keyPair.secretKey;
const publicKey = keyPair.publicKey;

В этом режиме любая попытка сохранения ключа в localStorage или IndexedDB сознательно исключается.


IndexedDB как слой долговременного хранения

IndexedDB применяется для хранения криптографических артефактов, но сам по себе не обеспечивает защиты. Это только структурированное хранилище, доступное из JavaScript.

Архитектурная проблема

Любые данные в IndexedDB:

  • доступны любому JS-коду в origin
  • уязвимы к XSS
  • не имеют встроенного шифрования

Поэтому приватные ключи нельзя хранить в открытом виде.


Шифрование ключей перед сохранением

Практический паттерн — key wrapping: приватный ключ хранится только в зашифрованном виде.

Схема

  1. пользователь вводит пароль
  2. из пароля выводится ключ шифрования
  3. приватный ключ шифруется
  4. зашифрованный blob сохраняется в IndexedDB

Использование Web Crypto API

Web Crypto API предоставляет нативные примитивы для PBKDF2, AES-GCM и RSA-OAEP (в зависимости от браузера).

Производный ключ из пароля

async function deriveKey(password, salt) {
  const enc = new TextEncoder();

  const baseKey = await crypto.subtle.importKey(
    "raw",
    enc.encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: 150000,
      hash: "SHA-256"
    },
    baseKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

Шифрование приватного ключа

async function encryptPrivateKey(privateKey, aesKey) {
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const encrypted = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    aesKey,
    privateKey
  );

  return { iv, encrypted };
}

Сохранение в IndexedDB

async function saveKey(db, id, payload) {
  const tx = db.transaction("keys", "readwrite");
  const store = tx.objectStore("keys");

  store.put({
    id,
    iv: payload.iv,
    data: payload.encrypted
  });

  return tx.complete;
}

Дешифрование при восстановлении сессии

async function decryptPrivateKey(encrypted, iv, aesKey) {
  return crypto.subtle.decrypt(
    { name: "AES-GCM", iv },
    aesKey,
    encrypted
  );
}

После восстановления ключ можно передать в TweetNaCl.js:

const keyPair = nacl.box.keyPair.fromSecretKey(new Uint8Array(privateKey));

Сравнение моделей хранения

Memory-only

  • приватный ключ не покидает RAM
  • максимальная устойчивость к persistent-кражам
  • минимальная сложность криптографической обвязки
  • требует повторной генерации или ввода при каждой сессии

IndexedDB + шифрование

  • устойчивость к перезагрузке
  • поддержка долгоживущих сессий
  • зависимость от качества парольной защиты
  • риск компрометации через XSS сохраняется

Типовые угрозы

XSS как основной вектор

Даже при использовании Web Crypto API злоумышленник с доступом к DOM может:

  • вызвать расшифровку ключа
  • перехватить результат в памяти
  • подменить функции криптографии

Компрометация расширений браузера

Расширения имеют доступ к контексту страницы и могут:

  • читать IndexedDB
  • перехватывать вызовы crypto.subtle
  • модифицировать bundle до выполнения

Изоляция криптографического слоя

Практический подход — минимизация доступности ключа:

  • хранение в closure без глобальных ссылок
  • использование Web Workers
  • отделение криптографии от UI-слоя

Пример через Worker:

// main thread
const worker = new Worker("crypto-worker.js");

worker.postMessage({ type: "init", password });

Memory + IndexedDB гибрид

Часто используется комбинированная модель:

  • при старте: загрузка зашифрованного ключа из IndexedDB
  • расшифровка → помещение в memory-only контейнер
  • при уходе в background: очистка памяти

Это снижает число операций с диском и уменьшает время существования уязвимого состояния.


Особенности интеграции с TweetNaCl.js

TweetNaCl.js не имеет встроенного менеджера ключей. Вся работа строится вокруг:

  • nacl.box.keyPair()
  • nacl.sign.keyPair()
  • raw Uint8Array ключей

Это означает:

  • хранение всегда внешнее по отношению к библиотеке
  • сериализация/десериализация полностью на стороне приложения
  • контроль формата ключей полностью ручной

Практическая модель принятия решения

Выбор стратегии зависит от требований:

  • сессионные приложения → memory-only
  • мессенджеры и долгие сессии → IndexedDB + WebCrypto wrapping
  • высокозащищённые сценарии → memory-only + повторная аутентификация

Ограничения браузерной криптографии

Независимо от выбранного подхода:

  • JavaScript не является доверенной средой
  • любое хранилище может быть прочитано при компрометации origin
  • безопасность определяется не только алгоритмами, но и архитектурой исполнения