Шифрование localStorage и IndexedDB

Модель угроз и ограничения браузерного хранилища

Хранилища браузера — localStorage и IndexedDB — изначально не предназначены для хранения чувствительных данных в открытом виде. Любая информация, сохранённая туда без шифрования, становится доступной:

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

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

Шифрование на клиенте с использованием TweetNaCl.js (nacl) решает задачу защиты данных «в покое» (at rest), превращая хранилище в контейнер для зашифрованных бинарных блоков.


Криптографическая база TweetNaCl.js

TweetNaCl.js реализует NaCl (Networking and Cryptography library) в JavaScript. Основной интерес для локального шифрования представляют:

  • nacl.secretbox — симметричное шифрование (XSalsa20 + Poly1305)
  • nacl.randomBytes — генерация криптографически стойких случайных значений
  • nacl.util.encodeBase64 / decodeBase64 — преобразование бинарных данных

Ключевой элемент модели — секретный ключ фиксированной длины:

  • 32 байта (Uint8Array)
  • должен быть одинаковым для шифрования и расшифрования

Базовая схема шифрования данных

secretbox требует:

  • message — Uint8Array
  • nonce — 24 байта (уникальный для каждого шифрования)
  • key — 32 байта

Результат — бинарный массив ciphertext.

Критически важно: nonce никогда не должен повторяться для одного и того же ключа.


Универсальные утилиты сериализации

localStorage принимает только строки, поэтому бинарные данные кодируются в Base64.

import nacl from "tweetnacl";
import util from "tweetnacl-util";

function toBase64(uint8) {
  return util.encodeBase64(uint8);
}

function fromBase64(str) {
  return util.decodeBase64(str);
}

Базовое шифрование и дешифрование

const key = nacl.randomBytes(32);

function encrypt(data, key) {
  const nonce = nacl.randomBytes(24);
  const messageUint8 = util.decodeUTF8(JSON.stringify(data));

  const box = nacl.secretbox(messageUint8, nonce, key);

  return {
    nonce: toBase64(nonce),
    box: toBase64(box)
  };
}

function decrypt(payload, key) {
  const nonce = fromBase64(payload.nonce);
  const box = fromBase64(payload.box);

  const message = nacl.secretbox.open(box, nonce, key);

  if (!message) {
    throw new Error("Decryption failed");
  }

  return JSON.parse(util.encodeUTF8(message));
}

Шифрование данных в localStorage

localStorage работает со строками, поэтому структура обычно сериализуется в JSON.

function saveEncryptedLocalStorage(keyName, data, key) {
  const encrypted = encrypt(data, key);
  localStorage.setItem(keyName, JSON.stringify(encrypted));
}

function loadEncryptedLocalStorage(keyName, key) {
  const raw = localStorage.getItem(keyName);
  if (!raw) return null;

  const parsed = JSON.parse(raw);
  return decrypt(parsed, key);
}

Управление nonce и предотвращение повторов

Повтор nonce при одном ключе полностью компрометирует безопасность secretbox.

Практика генерации:

  • всегда nacl.randomBytes(24)
  • не хранить nonce глобально
  • не использовать счётчики без защиты от коллизий

Более надёжная схема — хранение nonce вместе с ciphertext, как показано выше.


Шифрование структурированных данных в IndexedDB

IndexedDB работает с объектами и бинарными типами (Blob, ArrayBuffer, Uint8Array), что делает его удобнее для хранения зашифрованных данных без Base64.

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

  • encrypt → Uint8Array ciphertext
  • store nonce + ciphertext как бинарные поля

Пример работы с IndexedDB

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("secure-db", 1);

    request.onupgradenee ded = () => {
      const db = request.result;
      db.createObjectStore("vault", { keyPath: "id" });
    };

    request.onsucc ess = () => resolve(request.result);
    request.oner ror = () => reject(request.error);
  });
}

Запись зашифрованных данных в IndexedDB

async function saveEncryptedDB(id, data, key) {
  const db = await openDB();
  const tx = db.transaction("vault", "readwrite");
  const store = tx.objectStore("vault");

  const encrypted = encrypt(data, key);

  store.put({
    id,
    nonce: fromBase64(encrypted.nonce),
    box: fromBase64(encrypted.box)
  });

  return tx.complete;
}

Чтение и расшифровка из IndexedDB

async function loadEncryptedDB(id, key) {
  const db = await openDB();

  return new Promise((resolve, reject) => {
    const tx = db.transaction("vault", "readonly");
    const store = tx.objectStore("vault");
    const request = store.get(id);

    request.onsucc ess = () => {
      const result = request.result;
      if (!result) return resolve(null);

      const decrypted = nacl.secretbox.open(
        result.box,
        result.nonce,
        key
      );

      if (!decrypted) {
        return reject(new Error("Decryption failed"));
      }

      resolve(JSON.parse(util.encodeUTF8(decrypted)));
    };

    request.oner ror = () => reject(request.error);
  });
}

Хранение ключей и проблема доверенной среды

Ключ шифрования остаётся слабым звеном системы.

Типовые подходы:

  • хранение ключа в памяти (volatile)
  • генерация ключа при логине
  • derivation через пароль пользователя

Пример PBKDF2 через Web Crypto API:

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

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

  const bits = await crypto.subtle.deriveBits(
    {
      name: "PBKDF2",
      salt,
      iterations: 100000,
      hash: "SHA-256"
    },
    baseKey,
    256
  );

  return new Uint8Array(bits);
}

Этот ключ затем может использоваться в nacl.secretbox.


Типичные ошибки при интеграции

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

  • приводит к восстановлению XOR-разницы сообщений

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

  • полностью нивелирует смысл шифрования

Отсутствие версионирования схемы

  • невозможно обновить алгоритм без миграции данных

Шифрование без сериализации структуры

  • приводит к потере типов и данных при восстановлении

Версионирование зашифрованных данных

Практическая схема хранения:

{
  v: 1,
  nonce: "...",
  box: "...",
  alg: "nacl.secretbox"
}

Это позволяет:

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

Производительность и ограничения

secretbox работает быстро, но в браузере есть ограничения:

  • большие объёмы данных (>5–10MB) становятся заметно медленнее
  • сериализация JSON может быть узким местом
  • Base64 увеличивает размер примерно на 33%

IndexedDB предпочтительнее для:

  • больших объектов
  • частых операций записи
  • хранения бинарных данных без кодирования

Унифицированный модуль хранения

export const SecureStorage = {
  async setLocal(keyName, data, key) {
    const encrypted = encrypt(data, key);
    localStorage.setItem(keyName, JSON.stringify(encrypted));
  },

  async getLocal(keyName, key) {
    const raw = localStorage.getItem(keyName);
    if (!raw) return null;
    return decrypt(JSON.parse(raw), key);
  },

  async setDB(id, data, key) {
    const db = await openDB();
    const tx = db.transaction("vault", "readwrite");
    const store = tx.objectStore("vault");

    const encrypted = encrypt(data, key);

    store.put({
      id,
      nonce: fromBase64(encrypted.nonce),
      box: fromBase64(encrypted.box)
    });

    return tx.complete;
  }
};

Особенности применения в реальных приложениях

Использование TweetNaCl.js для шифрования браузерного хранилища эффективно в сценариях:

  • хранение токенов сессии
  • офлайн-заметки
  • локальные кошельки
  • конфигурации с приватными параметрами

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