localStorage не гарантирует целостность данных. Любое значение, сохранённое в нём, может быть изменено вручную через DevTools, подменено расширениями браузера или повреждено в результате XSS-атаки. Поэтому при работе с критичными данными требуется отдельный механизм проверки их подлинности и неизменности.
Основные сценарии нарушения целостности:
При этом важно учитывать: localStorage не предоставляет встроенной криптографической защиты, а значит любая проверка должна выполняться на уровне приложения.
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);
}
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;
}
Все операции crypto.subtle являются асинхронными. Это
влияет на архитектуру:
Хэш без секрета позволяет пользователю пересчитать контрольную сумму самостоятельно. Для защиты от подмены используется 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"]
);
}
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("");
}
async function saveSecure(keyName, value, hmacKey) {
const serialized = stableStringify(value);
const signature = await hmacSign(hmacKey, serialized);
localStorage.setItem(keyName, JSON.stringify({
value,
signature
}));
}
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;
}
Любое отличие в порядке ключей приводит к другому хэшу.
Невоспроизводимые значения делают проверку бессмысленной.
При HMAC ключ должен храниться вне клиентского хранилища, иначе защита теряет смысл.
Разные кодировки текста дают разные бинарные представления, что ломает проверку.
В реальных приложениях проверка целостности часто встраивается в слой абстракции хранилища:
Такой подход позволяет централизовать контроль и не дублировать криптографические операции по всему коду.