Практика: защищённое локальное хранилище

Локальное хранилище в браузере (localStorage, IndexedDB, Cache Storage) не обеспечивает конфиденциальность данных. Любая информация, записанная без шифрования, может быть прочитана через консоль разработчика, вредоносные скрипты при XSS-уязвимостях или расширения браузера.

Web Crypto API предоставляет нативные криптографические примитивы для защиты данных на стороне клиента. Основной сценарий для практики безопасного хранения — симметричное шифрование с использованием AES-GCM, где ключ либо генерируется и хранится в памяти сессии, либо выводится из пароля пользователя.

Базовая модель защищённого локального хранилища

Система безопасного хранения в браузере обычно строится по следующей схеме:

  • пользовательский ввод данных
  • генерация или получение криптографического ключа
  • шифрование данных через AES-GCM
  • сохранение зашифрованного payload в IndexedDB или localStorage
  • расшифровка при чтении с использованием того же ключа

Критически важный момент: ключ никогда не должен храниться в открытом виде в localStorage.

Генерация симметричного ключа AES-GCM

Web Crypto API позволяет создать криптографически стойкий ключ:

const key = await crypto.subtle.generateKey(
  {
    name: "AES-GCM",
    length: 256
  },
  true,
  ["encrypt", "decrypt"]
);

Параметр extractable: true позволяет экспортировать ключ при необходимости, но в защищённых сценариях предпочтительно использовать false, чтобы исключить возможность утечки.

Шифрование данных перед сохранением

AES-GCM требует уникальный initialization vector (IV) для каждой операции шифрования. Он не является секретом, но обязан быть уникальным.

function generateIV() {
  return crypto.getRandomValues(new Uint8Array(12));
}

async function encryptData(key, data) {
  const iv = generateIV();
  const encoded = new TextEncoder().encode(JSON.stringify(data));

  const ciphertext = await crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv
    },
    key,
    encoded
  );

  return {
    iv: Array.from(iv),
    ciphertext: Array.from(new Uint8Array(ciphertext))
  };
}

Результат шифрования преобразуется в массивы для хранения в JSON-формате.

Сохранение зашифрованного объекта в localStorage

После шифрования данные можно сохранять в браузере:

async function saveSecure(key, storageKey, data) {
  const encrypted = await encryptData(key, data);

  localStorage.setItem(storageKey, JSON.stringify(encrypted));
}

В таком виде данные бесполезны без ключа, даже если злоумышленник получит доступ к хранилищу.

Расшифровка данных из локального хранилища

Для восстановления данных необходимо извлечь IV и ciphertext и выполнить обратную операцию:

async function decryptData(key, encryptedObject) {
  const iv = new Uint8Array(encryptedObject.iv);
  const data = new Uint8Array(encryptedObject.ciphertext);

  const decrypted = await crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv
    },
    key,
    data
  );

  return JSON.parse(new TextDecoder().decode(decrypted));
}

Полный цикл работы с локальным защищённым хранилищем

async function secureStoreExample() {
  const key = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true,
    ["encrypt", "decrypt"]
  );

  const userData = {
    token: "abc123",
    role: "admin",
    timestamp: Date.now()
  };

  await saveSecure(key, "secure-data", userData);

  const stored = JSON.parse(localStorage.getItem("secure-data"));

  const decrypted = await decryptData(key, stored);

  return decrypted;
}

Использование ключа, полученного из пароля пользователя

Генерация случайного ключа удобна для сессий, но не подходит для постоянного хранения. В таких случаях используется PBKDF2.

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

async function deriveKey(password, salt) {
  const baseKey = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

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

Соль (salt) должна быть случайной и сохраняться вместе с зашифрованными данными.

Хранение соли и зашифрованного payload

async function saveWithPassword(password, data) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const key = await deriveKey(password, salt);

  const encrypted = await encryptData(key, data);

  const payload = {
    salt: Array.from(salt),
    encrypted
  };

  localStorage.setItem("secure-box", JSON.stringify(payload));
}

Восстановление данных с паролем

async function loadWithPassword(password) {
  const raw = JSON.parse(localStorage.getItem("secure-box"));

  const salt = new Uint8Array(raw.salt);
  const key = await deriveKey(password, salt);

  return decryptData(key, raw.encrypted);
}

Особенности использования AES-GCM в браузере

AES-GCM одновременно обеспечивает:

  • конфиденциальность данных
  • целостность (integrity check)
  • защиту от модификации зашифрованного текста

При изменении хотя бы одного байта ciphertext расшифровка завершится ошибкой.

Типичные ошибки при работе с Web Crypto API

Повторное использование IV

Использование одного и того же IV с одним ключом полностью ломает безопасность AES-GCM. Каждый вызов шифрования обязан использовать уникальный IV.

Хранение ключа в localStorage

Даже если данные зашифрованы, хранение ключа рядом полностью уничтожает смысл шифрования.

Использование слабых паролей

При PBKDF2 безопасность напрямую зависит от сложности пароля. Короткие или предсказуемые пароли легко подбираются.

Отсутствие контроля целостности структуры данных

Изменение структуры JSON без проверки может привести к ошибкам при расшифровке или потере данных.

Работа с IndexedDB вместо localStorage

localStorage ограничен по объёму и работает синхронно, что блокирует поток выполнения. IndexedDB предпочтительнее для больших объёмов зашифрованных данных.

Пример хранения:

function saveToIndexedDB(db, key, value) {
  const tx = db.transaction(["secureStore"], "readwrite");
  const store = tx.objectStore("secureStore");

  store.put(value, key);
}

Шифрование остаётся идентичным, изменяется только слой хранения.

Защита от XSS как часть криптографической модели

Криптография в браузере не защищает от выполнения вредоносного JavaScript. При наличии XSS атакующий может:

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

Поэтому Web Crypto API рассматривается как часть многоуровневой защиты, а не самостоятельное решение.

Асинхронная природа криптографических операций

Все операции Web Crypto API построены на Promise-based интерфейсе. Это связано с тем, что криптографические вычисления выполняются вне основного потока.

Это влияет на архитектуру:

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

Оптимизация работы с криптографией в клиентских приложениях

При интенсивной работе с шифрованием полезно:

  • кешировать ключ в памяти сессии
  • минимизировать количество преобразований Uint8Array ↔︎ string
  • группировать операции шифрования
  • избегать повторной генерации ключей

Использование структурированного формата хранения

Удобный формат для хранения зашифрованных данных:

{
  version: 1,
  algorithm: "AES-GCM",
  iv: [...],
  data: [...],
  salt: [...]
}

Версионирование позволяет в будущем менять алгоритмы без потери совместимости.

Совместимость и ограничения браузеров

Web Crypto API поддерживается всеми современными браузерами, но:

  • доступен только в secure context (HTTPS или localhost)
  • некоторые алгоритмы могут отсутствовать в старых версиях браузеров
  • экспорт/импорт ключей зависит от параметров extractable

Практическая архитектура защищённого хранилища

Типовая структура модуля:

  • KeyManager (генерация и derivation ключей)
  • CryptoService (encrypt/decrypt)
  • StorageAdapter (localStorage / IndexedDB)
  • SecureRepository (единый интерфейс доступа к данным)

Такое разделение позволяет изолировать криптографию от бизнес-логики и упростить замену хранилища без изменения логики шифрования