Практика: проверка целостности данных в localStorage

localStorage не гарантирует целостность данных. Любое значение, сохранённое в нём, может быть изменено вручную через DevTools, подменено расширениями браузера или повреждено в результате XSS-атаки. Поэтому при работе с критичными данными требуется отдельный механизм проверки их подлинности и неизменности.


Основные сценарии нарушения целостности:

  • ручное редактирование значений через инструменты разработчика
  • внедрение JavaScript при XSS и модификация хранилища
  • расширения браузера, имеющие доступ к DOM и Storage
  • некорректная сериализация и повторное сохранение данных

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


Базовый подход: контрольная сумма через SHA-256

Web Crypto API предоставляет доступ к криптографическим примитивам через crypto.subtle. Для проверки целостности чаще всего используется хэширование.

H = (M)

где:

  • M — сериализованные данные
  • H — хэш фиксированной длины

Сериализация данных перед хэшированием

Ключевая ошибка при работе с JSON — нестабильный порядок ключей и различия в форматировании.

Для корректного результата требуется детерминированная сериализация:

function stableStringify(obj) {
  if (obj === null || typeof obj !== "object") {
    return JSON.stringify(obj);
  }

  if (Array.isArray(obj)) {
    return `[${obj.map(stableStringify).join(",")}]`;
  }

  const keys = Object.keys(obj).sort();
  const pairs = keys.map(key => {
    return JSON.stringify(key) + ":" + stableStringify(obj[key]);
  });

  return `{${pairs.join(",")}}`;
}

Преобразование строки в бинарный формат

WebCrypto работает с ArrayBuffer, поэтому требуется кодирование:

function encodeText(text) {
  return new TextEncoder().encode(text);
}

Вычисление SHA-256 через Web Crypto API

async function sha256(message) {
  const data = encodeText(message);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

Сохранение данных с проверкой целостности

Идея заключается в том, что в localStorage сохраняется не только данные, но и их хэш.

async function saveWithIntegrity(key, value) {
  const serialized = stableStringify(value);
  const hash = await sha256(serialized);

  const payload = {
    value,
    hash
  };

  localStorage.setItem(key, JSON.stringify(payload));
}

Проверка данных при чтении

При загрузке необходимо пересчитать хэш и сравнить его с сохранённым.

async function loadWithIntegrity(key) {
  const raw = localStorage.getItem(key);
  if (!raw) return null;

  const parsed = JSON.parse(raw);
  const serialized = stableStringify(parsed.value);
  const expectedHash = await sha256(serialized);

  if (expectedHash !== parsed.hash) {
    throw new Error("Нарушена целостность данных");
  }

  return parsed.value;
}

Особенности работы с async Web Crypto API

Все операции crypto.subtle являются асинхронными. Это влияет на архитектуру:

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

Использование HMAC вместо простого хэша

Хэш без секрета позволяет пользователю пересчитать контрольную сумму самостоятельно. Для защиты от подмены используется HMAC.

(K, M)

где:

  • K — секретный ключ
  • M — сообщение

Генерация и импорт ключа

async function getKey() {
  const keyMaterial = new TextEncoder().encode("super-secret-key");

  return await crypto.subtle.importKey(
    "raw",
    keyMaterial,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"]
  );
}

Подпись данных через HMAC

async function hmacSign(key, data) {
  const encoded = new TextEncoder().encode(data);
  const signature = await crypto.subtle.sign("HMAC", key, encoded);

  return Array.from(new Uint8Array(signature))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

Сохранение с HMAC-защитой

async function saveSecure(keyName, value, hmacKey) {
  const serialized = stableStringify(value);
  const signature = await hmacSign(hmacKey, serialized);

  localStorage.setItem(keyName, JSON.stringify({
    value,
    signature
  }));
}

Проверка HMAC при загрузке

async function loadSecure(keyName, hmacKey) {
  const raw = localStorage.getItem(keyName);
  if (!raw) return null;

  const parsed = JSON.parse(raw);
  const serialized = stableStringify(parsed.value);

  const expected = await hmacSign(hmacKey, serialized);

  if (expected !== parsed.signature) {
    throw new Error("Данные изменены или повреждены");
  }

  return parsed.value;
}

Типичные ошибки при реализации

Нестабильная сериализация JSON

Любое отличие в порядке ключей приводит к другому хэшу.

Использование Math.random в данных

Невоспроизводимые значения делают проверку бессмысленной.

Сохранение секретного ключа в localStorage

При HMAC ключ должен храниться вне клиентского хранилища, иначе защита теряет смысл.

Игнорирование кодировки

Разные кодировки текста дают разные бинарные представления, что ломает проверку.


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

  • Web Crypto API доступен только в secure context (HTTPS)
  • операции digest и sign асинхронны и могут влиять на производительность при больших объёмах данных
  • localStorage имеет ограничение по размеру (обычно около 5–10 МБ)

Архитектурные подходы к интеграции

В реальных приложениях проверка целостности часто встраивается в слой абстракции хранилища:

  • обёртка над localStorage
  • middleware для state persistence (Redux, Zustand)
  • сервисный слой хранения конфигурации

Такой подход позволяет централизовать контроль и не дублировать криптографические операции по всему коду.


Дополнительные усиления модели защиты

  • добавление версии схемы данных в payload
  • использование salt при хэшировании
  • периодическая ротация HMAC-ключей
  • комбинирование с подписью серверного происхождения для критичных данных