Безопасное хранение токенов и сессионных данных

Хранение сессионных данных в браузере всегда начинается с определения границ доверия. Основная проблема заключается в том, что клиентская среда считается недоверенной: любой скрипт, попавший в контекст страницы, получает доступ к памяти приложения, DOM и большинству API хранения.

Ключевые угрозы:

  • XSS-инъекции, позволяющие читать localStorage, sessionStorage, перехватывать переменные в памяти
  • подмена скриптов через supply chain (CDN, зависимости)
  • утечки через расширения браузера
  • утечки через логирование и аналитические SDK
  • кража токенов при отсутствии изоляции между вкладками

Любое решение на базе TweetNaCl.js / nacl.js должно проектироваться с учётом того, что шифрование не защищает от XSS, но может ограничить ущерб при утечке хранилища.


Форматы хранения сессионных данных

Память приложения (in-memory storage)

Самый безопасный с точки зрения персистентности вариант.

Особенности:

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

Используется для:

  • access token с коротким TTL
  • временные ключи шифрования
  • состояния авторизации
let accessToken = null;
let sessionKey = null;

Минус — необходимость повторной аутентификации или refresh-механизма.


sessionStorage

Изолировано на уровень вкладки.

Характеристики:

  • очищается при закрытии вкладки
  • доступно JavaScript в рамках origin
  • уязвимо для XSS
sessionStorage.setItem("access_token", token);
const token = sessionStorage.getItem("access_token");

Используется для менее критичных сценариев, чем persistent storage.


localStorage

Наиболее опасный вариант хранения токенов.

Проблемы:

  • долгоживущие данные
  • доступ из любого скрипта
  • легко извлекается при XSS

Использование допустимо только для:

  • зашифрованных данных
  • некритичных настроек
  • публичных идентификаторов

IndexedDB

Более гибкое хранилище с асинхронным API.

Преимущества:

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

Применение TweetNaCl.js для защиты данных

Библиотека TweetNaCl.js реализует криптографические primitives:

  • nacl.secretbox — симметричное шифрование
  • nacl.box — публично-ключевая криптография
  • nacl.sign — цифровые подписи
  • nacl.randomBytes — генерация nonce и ключей

Базовая модель: шифрование токена перед хранением

Основная идея — хранить не токен, а его зашифрованную форму.

Генерация ключа

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

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

const key = nacl.randomBytes(32);

В реальных системах ключ чаще выводится из:

  • пароля пользователя (PBKDF2 / Argon2 через WebCrypto)
  • аппаратного контекста (WebAuthn)
  • серверного временного секрета

Симметричное шифрование secretbox

Особенности алгоритма

secretbox использует:

  • XSalsa20 (шифрование)
  • Poly1305 (аутентификация)
  • nonce (одноразовый вектор)

Критически важно:

nonce нельзя повторно использовать с тем же ключом


Шифрование токена

function encryptToken(token, key) {
  const nonce = nacl.randomBytes(24);
  const message = util.decodeUTF8(token);

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

  return {
    nonce: util.encodeBase64(nonce),
    box: util.encodeBase64(box)
  };
}

Расшифрование

function decryptToken(encrypted, key) {
  const nonce = util.decodeBase64(encrypted.nonce);
  const box = util.decodeBase64(encrypted.box);

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

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

  return util.encodeUTF8(message);
}

Хранение зашифрованного токена

localStorage вариант

const encrypted = encryptToken(accessToken, key);

localStorage.setItem("auth_token", JSON.stringify(encrypted));

восстановление

const stored = JSON.parse(localStorage.getItem("auth_token"));

const token = decryptToken(stored, key);

Проблема ключа шифрования

Основная слабость всей схемы заключается в том, что:

если ключ хранится рядом с данными — защита теряет смысл

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

1. Ключ в памяти

  • максимальная безопасность
  • потеря при refresh

2. Ключ из пароля пользователя

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

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

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

  return new Uint8Array(derived);
}

Далее ключ можно использовать в TweetNaCl.js после преобразования.


Комбинированная модель хранения сессии

На практике применяется гибрид:

  • access token — в памяти
  • refresh token — в зашифрованном хранилище
  • ключ — производный или временный

Защита от повторного использования nonce

nonce должен быть:

  • случайным
  • уникальным для каждой операции
const nonce = nacl.randomBytes(24);

Ошибки:

  • использование фиксированного nonce
  • повтор nonce при повторном шифровании токена

Последствия:

  • полная компрометация секретов при повторении nonce

Подпись сессионных данных

Дополнительный уровень — nacl.sign.

Используется для:

  • защиты целостности
  • предотвращения подмены данных
const keyPair = nacl.sign.keyPair();

const message = util.decodeUTF8("session-data");

const signed = nacl.sign(message, keyPair.secretKey);

Проверка:

const verified = nacl.sign.open(signed, keyPair.publicKey);

if (!verified) {
  throw new Error("Invalid signature");
}

Защита от XSS при криптографическом хранении

Даже при использовании TweetNaCl.js шифрование не компенсирует XSS.

Если атакующий выполняет JavaScript в контексте страницы:

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

Следовательно, криптография решает только:

  • защиту данных “at rest”
  • защиту localStorage/IndexedDB при утечке

Ротация токенов

Практика снижения ущерба:

  • короткоживущие access token
  • регулярная замена refresh token
  • привязка токенов к устройству

Структура защищённой сессии

{
  accessToken: null,
  encryptedRefreshToken: {
    nonce: "...",
    box: "..."
  },
  keyId: "device-derived-key",
  issuedAt: 1710000000
}

Ошибки реализации

Наиболее частые проблемы:

  • хранение ключа в localStorage рядом с зашифрованным токеном
  • отсутствие nonce-уникальности
  • использование одного ключа для всех пользователей устройства
  • отсутствие защиты от XSS при внедрении библиотеки
  • доверие к клиентскому шифрованию как к основной защите

Практическая модель безопасности

Комбинация уровней:

  • HttpOnly cookies для критичных сессий (если возможно)
  • in-memory storage для access token
  • secretbox для локального хранения refresh token
  • WebCrypto для derivation ключей
  • CSP для ограничения XSS
  • строгая изоляция сторонних скриптов

Использование nacl.js vs TweetNaCl.js

  • TweetNaCl.js — минималистичная и безопасная реализация
  • nacl.js — обёртки и совместимость, но чаще менее актуальна

В современных приложениях предпочтение отдаётся TweetNaCl.js как reference implementation.


Минимальный безопасный шаблон

const key = nacl.randomBytes(32);

function storeSession(token) {
  const encrypted = encryptToken(token, key);
  sessionStorage.setItem("session", JSON.stringify(encrypted));
}

function loadSession() {
  const raw = sessionStorage.getItem("session");
  if (!raw) return null;

  const encrypted = JSON.parse(raw);
  return decryptToken(encrypted, key);
}

Ограничения криптографической модели в браузере

Любая реализация в JavaScript сталкивается с фундаментальными ограничениями:

  • отсутствие полного контроля над окружением выполнения
  • невозможность скрыть секрет от кода страницы
  • зависимость от целостности DOM и скриптов

Криптографические примитивы TweetNaCl.js повышают устойчивость к утечкам хранилища, но не заменяют архитектурные меры защиты сессий.