Хранилища браузера — localStorage и
IndexedDB — изначально не предназначены для хранения
чувствительных данных в открытом виде. Любая информация, сохранённая
туда без шифрования, становится доступной:
localStorage особенно уязвим из-за синхронного доступа и
простоты чтения. IndexedDB даёт более структурированное
хранение, но не добавляет криптографической защиты.
Шифрование на клиенте с использованием TweetNaCl.js
(nacl) решает задачу защиты данных «в покое» (at rest),
превращая хранилище в контейнер для зашифрованных бинарных блоков.
TweetNaCl.js реализует NaCl (Networking and Cryptography library) в JavaScript. Основной интерес для локального шифрования представляют:
nacl.secretbox — симметричное шифрование (XSalsa20 +
Poly1305)nacl.randomBytes — генерация криптографически стойких
случайных значенийnacl.util.encodeBase64 / decodeBase64 — преобразование
бинарных данныхКлючевой элемент модели — секретный ключ фиксированной длины:
secretbox требует:
message — Uint8Arraynonce — 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 работает со строками, поэтому структура
обычно сериализуется в 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 при одном ключе полностью компрометирует безопасность
secretbox.
Практика генерации:
nacl.randomBytes(24)Более надёжная схема — хранение nonce вместе с ciphertext, как показано выше.
IndexedDB работает с объектами и бинарными типами (Blob,
ArrayBuffer, Uint8Array), что делает его
удобнее для хранения зашифрованных данных без Base64.
Схема хранения:
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);
});
}
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;
}
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);
});
}
Ключ шифрования остаётся слабым звеном системы.
Типовые подходы:
Пример 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
Хранение ключа в localStorage
Отсутствие версионирования схемы
Шифрование без сериализации структуры
Практическая схема хранения:
{
v: 1,
nonce: "...",
box: "...",
alg: "nacl.secretbox"
}
Это позволяет:
secretbox работает быстро, но в браузере есть
ограничения:
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 для шифрования браузерного хранилища эффективно в сценариях:
При этом архитектура должна учитывать, что защита действует только при компрометации хранилища, но не защищает от выполнения вредоносного кода в самом приложении.